#pragma mode( separator(.,;) integer(h32) )
// ============================================================
// EPISTATS - v1.7d
// ============================================================
// Author: Roger Metcalf, 2026.  Written with AI assistance
//   (Anthropic Claude); concept, direction and all hardware
//   testing by the author.
//
// FREE FOR EDUCATIONAL AND PERSONAL USE.  Keep this header with
//   the file and credit the author if you pass it on.  Not for
//   sale or commercial redistribution.  Provided as is, with no
//   warranty: check any result you intend to rely on.
// DISCLAIMER: a teaching and demonstration aid. Not validated for
//   forensic, clinical or legal use; do not rely on its results for
//   legal purposes.
//
// Firmware: this version has NOT yet been run on hardware or on the
//   emulator: v1.7d has run only in the PC harness (ppl_run_v3e);
//   v1.7c never ran on the emulator either.  (Neither have v1.7b,
//   v1.7a, v1.7, v1.6, v1.5 and v1.4 run on hardware.)  Written for the
//   2026-09-09 firmware (2.4.2); every touch and drawing idiom it uses
//   is copied from ChiTable beta 1k, which ran on a G2 and a G1 on 10
//   Oct 2026.  On the HP Prime Virtual Calculator (build 2026-09-09,
//   the calculators' own firmware): round 1 (16-17 Oct 2026) ran
//   v1.6's 2x2 path, every number = SciPy; round 2 (17 Oct 2026) ran
//   v1.7a's Help F, "Where the SE comes from", with real CAS answers:
//   "exact (CAS)", = 53/120, SE = 0.6646.  The CAS never failed
//   there, so the fallback fixed in v1.7b has run only in the PC
//   harness (ppl_run_v3d).
//
// What changed in v1.7d (19 Oct 2026) -- E1, E2, E3, E4, E6, E7, E8,
//   E9, E10 and E11 of the fleet review (19 Oct 2026); line numbers
//   are v1.7c's.  Every ordinary display is unchanged.
//   - E1 (a probable crash): the four sample-size forms (5210, 5274,
//     5334, 5386) never checked alpha or power, so alpha = 0, power = 1,
//     alpha = 2 ... reached NORMALD_ICDF(0, 1, 1), (0, 1, 0) or
//     (0, 1, 1.5), whose answer on the Prime has never been seen (an
//     error, or an infinite z and then CEILING of it).  Each form now
//     refuses alpha outside (0, 1), and the two power forms power
//     outside (0, 1), with a message, BEFORE any NORMALD_ICDF.
//   - E2 (a wrong number): Risk Analysis read an empty row as a risk of
//     0 % (3052-3053): grid 0 0 5 5 printed "Risk in Exposed 0.0%",
//     "RD = -50.0%", "NNT (benefit) = 2.0" and drew the icon array.  A
//     risk is a count over the people in its row; with no people there
//     is no risk.  The module now refuses such a table at entry, as
//     Incidence Rates refuses zero person-time ("A risk needs people in
//     both rows: one row of the table is empty."), before any EP_* is
//     set.  Module 1 still analyses it: chi-square and Fisher hold.
//   - E3 (a wrong number): Sensitivity & Specificity read an empty
//     column as sensitivity (or specificity) 0 % (3962-3976): grid
//     0 5 0 30 printed "Sensitivity: 0.0%", "LR+ 0.00", "LR- 1.167",
//     "FNR 100.0%" and "Youden's J -0.143".  Page 1 now reads "undef (no
//     Disease+)" or "undef (no Disease-)" for the missing one, its LRs
//     (each needs both), "undef" for FPR or FNR, and Youden's J reads
//     "undef" on page 2; EP_LRpos, EP_LRneg and EP_youden hold the 999
//     sentinel then (es_FmtNum prints it "undef").  es_SS_Page1 takes
//     two more arguments, sOK1 and sOK2 (8 -> 10).
//   - E4 (wrong text): the 2x2 page printed "RD = Re - Ru = 0.0%
//     (-100.0-100.0%)" when a row is empty (2724-2731), under a worked
//     box that says RD is undefined.  RD now holds the 999 sentinel
//     ("undef") and EP_RD_CI {0, 0} (no CI shown), as RR already did.
//     Help > Variables names the 999 sentinel.
//   - E7: Risk Analysis printed an NNT for any RD but 0.  A table such
//     as 1 999999 1 1000000 (|RD| about 1E-12) drew "NNT = 1/< 0.000001
//     = 1.00E12, round up: 1E12" and "Expose 1E12 (blue)" on page 5
//     (3449-3451) and "For every 1.00000100011E12 exposed" on page 3
//     (3250): the Prime's scientific form, for an NNT that means
//     nothing.  Below |RD| = 0.000001 (an NNT above 1,000,000), page 3
//     now reads "NNT (harm) = 1/RD   > 1,000,000" with "RD near 0 ... no
//     meaningful NNT", page 5 says the same and draws no icons, and
//     EP_NNT holds the 999 sentinel, as for RD = 0 (3091-3097, 3239-
//     3264, 3416-3427).  Every RD from 0.000001 up draws as before.
//   - E8: the four remaining 1 - CHISQUARE_CDF (McNemar 742; the M-H
//     chi-square 4691 and its corrected form 4694; Breslow-Day 4718)
//     are exactly 0 once the tail is below the CDF's last digit (a
//     chi-square above about 52 on the Prime), so EP_MCN_P, EP_MH_P and
//     EP_BD_P read 0 where EP_chisq_p (v1.7c) keeps its digits.  All
//     four have 1 df here (Breslow-Day's K - 1 with the two strata this
//     module takes), and P(chi-square_1 > x) = 2 NORMALD_CDF(-sqrt x),
//     a lower tail, kept to its last digit.  Screens are unchanged
//     (es_PTxt already printed "p < 0.0001").
//   - E6: Incidence Rates page 3 printed "1000000.001 time units" from
//     x 200, off the screen.  The number stands alone; "(time units)"
//     moved into the heading, "Mean Waiting Times (time units)", which
//     says "Mean" for "Average" so that it fits as well (4425-4434).
//   - E9: Incidence Rates took 2.5 cases as typed (4269-4285).  Cases
//     are counts: a fractional count is rounded, with a message, as the
//     grid already does for its counts.
//   - E10: the comment banner (304) said v1.7b; the menu title (538),
//     About (5475) and the banner say v1.7d.
//   - A DISCLAIMER line beside the distribution terms above (Roger, 19
//     Oct 2026): a teaching and demonstration aid, not validated for
//     forensic, clinical or legal use.  A comment only (About states no
//     terms, so it is unchanged).
//   - E11: es_Cnt (1455-1459) printed a person-time below 0.1 typed in
//     the rates grid as STRING(x, 2, 1), the Prime's scientific form
//     ("5.0E-2" for 0.05).  Below 0.1 it now prints three significant
//     figures (es_SigTxt: "0.0500"); a Haldane half (12.5) and every
//     whole count print as before.
//   Left for Roger (fleet review section 3): D10, the name of the
//   two-proportion sample-size formula (the page does not name it; kept
//   as it is), and whether "OR = undef (1.43-760.67)" shows the +0.5
//   estimate or blanks the CI (E5, unchanged).
//
// What changed in v1.7c (17 Oct 2026, evening) -- no "=" before a
//   relation (Roger approved the pass, 17 Oct 2026, 21:18; line numbers
//   are v1.7b's):
//   - es_FmtNum writes "< 0.001" (or "> -0.001") below its floor and
//     es_SigTxt "< 0.000001"; 29 displays put that after "= ".  A cohort
//     of 1 case in 100,000 exposed read "RR = Re/Ru = < 0.001"; 1 case in
//     200,000 person-time "= < 0.01 per 1,000 person-time"; a power of
//     0.5001 "z_β = < 0.001".  They now read "RR = Re/Ru < 0.001" and so
//     on, through es_EqTxt (755) where the label and the value are one
//     string: the readings (2135, 2136, 2192), page 7 (2470), the
//     ROC page (3693, 3696), the strata (4717, 4793, 4795,
//     4997, 5004) and the sample-size pages (5159, 5222, 5273, 5324).
//     Where the label is drawn apart from its value (3148, 3155, 3169,
//     4319, 4324, 4331, 4370, 4375), new es_EqLb drops the label's
//     closing "="; on the "= 0.50 per 1,000" lines (4298, 4303, 4308,
//     4327) new es_EqV drops the leading "= ".
//   - P-VALUE NEVER 0.  The 2 x 2 page's chi-square p and Yates p and
//     the summary's p (2603, 2618, 3996) were 1 - CHISQUARE_CDF, which
//     is exactly 0 once the tail is below the CDF's last digit (a
//     chi-square above about 52 on the Prime): "0.00000 *" where a
//     smaller chi-square read "< 0.00001 *".  With 1 df the tail is
//     2 NORMALD_CDF(0, 1, -sqrt(chi-square)), the same number, kept
//     to its last digit.  The summary's compact "p=" reads "p<0.0001"
//     below its floor (4173).
//   - The version on screen (the menu title, 511, and About, 5395)
//     reads v1.7c.  Nothing else changed.
//
// What changed in v1.7b (17 Oct 2026) -- two string rules that
//   emulator round 2 found, typed in Home (line numbers are v1.7a's):
//   - INSTRING("", "a") is "Error: Invalid input", and the program
//     stops.  es_Cas (760-783) sets cR := "" when the CAS fails, then
//     tested INSTRING(cR, "undef") (778): a failed CAS call would
//     have stopped Help F and module 3 page 6 instead of showing the
//     decimal and the "decimal" badge.  The test now runs only when
//     DIM(cR) > 0.  That is the file's only INSTRING; every LEFT (719,
//     772) and MID (772, 773, 794, 797) has a count and a start of 1
//     or more, so none can meet LEFT(s, 0) = the whole string or
//     MID(s, 0, n) = "Bad argument type".
//   - STRING(x, 2, d) prints in SCIENTIFIC form when 0 < |x| < 10^-d:
//     STRING(0.096, 2, 1) = "9.6ᴇ-2", STRING(0.0004, 2, 3) =
//     "4.000ᴇ-4".  Every fixed-decimal display that a real table can
//     push below 10^-d now rounds first, STRING(ROUND(x, d), 2, d), so
//     it reads 0.0 or 0.000: es_FmtPct (1216), es_FmtCI (1230, 1232;
//     a Wilson lower bound of 0.02 %, an OR CI from 0.0027), es_Sgn
//     (1349-1350), the count message (1710), es_Arith (1800-1890), the
//     mosaic panel (1993, 1996), the three readings (2142-2169), page
//     7's null mean (2490), the risk bars' OR-vs-RR lines (3270,
//     3284-3288), es_PctTxt and the prevalence page (3387, 3410-3411,
//     3441, 3455), the ROC page (3627-3636), page 6's closed-form
//     decimal (3761), the M-H summary (4680, 4746-4753), the forest
//     plot's weights (4875) and Help F (5617-5625).  Two lines read
//     differently: the NNT page (3328) shows an RD below 0.001 to
//     three significant figures (es_SigTxt: "NNT = 1/0.000400 =
//     2500.00"), and the forest plot's Breslow-Day p (4951) uses
//     es_PTxt ("p < 0.01"), as the M-H summary does.  es_PTxt,
//     es_FmtNum and es_SigTxt already kept their values at 10^-d or
//     more; counts, axis labels and the rest cannot reach the case
//     (CHANGES lists them).  The version on screen (the menu title,
//     473, and About, 5333) reads v1.7b.  Nothing else changed.
//
//
// What changed in v1.7a (17 Oct 2026) -- the CAS call form only.
//   CASProbe beta 1b found that CAS(s) with s a variable hands the
//   text back UNEVALUATED, so v1.7's es_Cas (734-760) returned its own
//   input: page 6's closed form would have read "(1/40)^(1/30)" with
//   the badge "exact (CAS)" by luck (the input IS the answer there),
//   and Help F would have shown the unevaluated "diff(...)" and the
//   unadded sum of fractions as if they were the CAS's answers.  Its
//   IFERR line is now
//     STRING(EXPR("CAS(" + CHAR(34) + "string(" + s + ")" + CHAR(34) + ")"))
//   (BRIEF_CAS_v2), the quotes STRING() adds are dropped, and an
//   answer holding the infinity sign is refused like undef.  No
//   reader changed: every answer is shown as the CAS writes it
//   (es_CasShow), and the one test on an answer, esDvC == "0", reads
//   a "0" that prints alike everywhere.  On the calculator the n = 2
//   closed form reads 1-√(1/40) (the root sign, CASProbe T14b), not
//   1-sqrt(1/40).  The version on screen (the main menu's title, 454,
//   and the About page, 5309) reads v1.7a.  Nothing else changed.
//
// What changed in v1.7 (16 Oct 2026) -- the EXACT version, built on
//   v1.6 (every v1.6, v1.5 and v1.4 change kept; line numbers below
//   are v1.6's).  NOT yet run on hardware.
//   - Exact (Clopper-Pearson) intervals beside Wilson's, from ONE
//     routine, es_CPB / es_CPInt: bisection on BINOMIAL_CDF with a
//     Newton step when it lands inside the bracket (the slope is
//     n BINOMIAL(n-1, p, k)).  The same routine, line for line, is in
//     CICatcher beta 2.  Checked against statsmodels
//     proportion_confint(method='beta') to 1E-8 over 11,670 intervals.
//     mid-P (half of P(X = k) in each tail) from the same routine.
//     Shown: module 1 page 1, Diagnostic tab (sens, spec; 1588-1617);
//     module 2 page 4 (orange whiskers beside Wilson's, 2469-2562);
//     module 3 NEW page 6, "EXACT vs WILSON": the four proportions as
//     three bars each (Wilson, exact, mid-P), tap a row for its
//     numbers.  Wilson is kept everywhere it was.
//   - The conditional MLE odds ratio (the one R's fisher.test and
//     SciPy report) and its exact conditional CI, computed in PPL:
//     es_CondOR solves E[a | psi] = a (and the two tail equations)
//     under the noncentral hypergeometric by bisection on ln(psi)
//     with Newton steps, summing only the terms that matter.  Shown
//     on module 1 page 1 under the sample OR, and on NEW page 7,
//     "EXACT ODDS RATIO": the distribution of a at the MLE (orange)
//     against OR = 1 (grey), and why the two odds ratios differ.
//     Checked against scipy.stats.contingency.odds_ratio.
//   - The CAS, behind the fleet's fence (BRIEF_CAS): ONE call
//     routine, es_Cas; CAS text built only from whole numbers
//     (es_IStr); events only, cached; a decimal fallback and an
//     "exact (CAS)" / "decimal" badge.  Help F, "Where the SE comes
//     from": the CAS differentiates ln(p/(1-p)) live and adds up
//     1/a + 1/b + 1/c + 1/d for your table as an exact fraction.
//     Module 3 page 6: the closed form of an exact end when the
//     count is 0 or n, e.g. upper = 1-(1/40)^(1/20).
//   - New results: EP_sens_CP, EP_spec_CP, EP_ppv_CP, EP_npv_CP,
//     EP_risk_exp_CP, EP_risk_unexp_CP, EP_OR_CMLE, EP_OR_CMLE_CI.
//   - Module 1 has seven pages (was six), module 3 six (was five).
//   - Help: the menu has 16 items, so it uses CHOOSE's list form
//     (the simple form stops at 14); new items E "Exact CIs, Exact
//     OR" and F "Where the SE Comes From"; About, CIs, Variables and
//     Result-page help updated.
//   - A one-time check at start (es_BinCheck): BINOMIAL_CDF and
//     BINOMIAL must give two known values, or the exact intervals
//     say "unavailable" instead of showing wrong numbers.
//
// What changed in v1.6 (16 Oct 2026) -- built on v1.5 (every v1.5
//   and v1.4 fix kept; line numbers below are v1.5's).  NOT yet run
//   on hardware.
//   - ConTable2 v1.3 and FEP v1.4 are folded in (both go to
//     _superseded).  What moved here:
//       ConTable2: McNemar's test with continuity correction, Yule's
//         Q and Y, the error rate (b+c)/N, Fisher's point
//         probability, the three ways of reading one 2x2;
//       FEP: Fisher's point probability beside the two-sided p;
//       both: the one-line Home call, now EpiStats2x2(a, b, c, d),
//         which opens module 1's results and returns the two-sided
//         Fisher p (-1 above N = 2000).  Their Pearson/Yates/Fisher
//         code is NOT copied: module 1 already had it, Fisher in log
//         space (no COMB overflow), so their N <= 1500 cap is gone.
//   - Module 1 has six pages (was four).  NEW page 1, THE THREE
//     READINGS: tabs Cohort / Diagnostic / Paired relabel the same
//     four counts and show only that reading's statistics (risks,
//     RR, OR, RD, AF; sens, spec, PPV, NPV, LR, Youden, accuracy and
//     error rate; kappa, McNemar, Yule's Q and Y, disagreement).
//     Page 2 = v1.5 page 1; page 3 = v1.5 page 2 plus the point
//     probability (1476-1487); NEW page 4, the Fisher bars: one bar
//     per possible table with its probability, yours red, those no
//     more likely magenta (their sum = the two-sided p), the rest
//     grey; tap a bar for its table in a corner panel; page 5 = v1.5
//     page 3 with the tap-for-arithmetic; page 6 = the mosaic.
//   - Module 2 has five pages (was three): page 4, the two risks as
//     bars with 95 % Wilson CIs and the RD as a magenta bracket, and
//     "OR overstates RR by X % here because the outcome is common
//     (Ru = ...)" worked out from the data; page 5, a 10 x 10 icon
//     array for the NNT (CEILING(NNT) cells, one red extra case or
//     one green prevented case).
//   - Module 5 (stratified, 2631-3060 rewritten): each stratum is
//     typed in the touch grid (stratum 1, then 2; Values still opens
//     the v1.4 form); one engine, es_MHCalc, for OR, RR and IRR:
//     Robins-Breslow-Greenland CI for OR_MH, Greenland-Robins CIs
//     for RR_MH and IRR_MH, the M-H chi-square (with and without
//     continuity correction) and Breslow-Day (OR), all checked
//     against statsmodels (verify_EpiStats.py).  The red "Confounding
//     likely present" verdicts (2797-2800, 2916-2919, 3048-3049) are
//     replaced by the 10 % change-in-estimate stated as a rule of
//     thumb beside the Breslow-Day p.  Page 3 of each design is a
//     forest plot; tap a stratum to drop it from the pool and watch
//     the M-H estimate change.
//   - New results: EP_ORMH_CI, EP_RRMH_CI, EP_IRRMH_CI, EP_MH_CHI,
//     EP_MH_P, EP_BD_P, EP_MCN_P.  Every v1.5 EP_* kept.
//   - Help: two new pages (C, D: readings and Fisher bars; forest
//     plot, risk bars and NNT); About, Mantel-Haenszel, variables,
//     grid and result-page help updated.  Footers in the house form.
//
// What changed in v1.5 (16 Oct 2026) -- the TOUCH version, built
//   on the v1.4 fix version (every v1.4 fix kept; line numbers
//   below are v1.4's):
//   - Every page is composed on G1 and shown with ONE
//     BLIT_P(G0,0,0,G1): es_DrawHeader (315) now starts the frame
//     with DIMGROB_P and es_DrawFooter (331) ends it with the blit.
//     No drawing call targets G0 any more (338 calls moved).
//   - A touch 2x2 entry grid, es_Grid, ported from ChiTable's
//     csGrid/csEntry, replaces the INPUT forms of modules 1, 2 and
//     3 (464-473, 758-767, 1025-1034): tap a cell, type its count;
//     totals and N update live; red frame on the selected cell;
//     arrows move it; soft keys Edit, Values, Clear, Cancel, Run.
//     Values opens the module's v1.4 four-field form (and runs
//     straight away when it is accepted).  Each module remembers
//     its last table while the program runs.  Module 5 keeps its
//     two four-field forms in this version.
//   - Tap to turn pages on every multi-page result (left half =
//     back, right half = on; a tap never leaves the first or the
//     last page; Esc = menu); the arrows still work as in v1.4.
//     Single-page screens and help pages: any key or tap = back.
//     es_WaitAny holds every page (keys AND touch drained, the
//     finger-lift drain capped at one second).
//   - Module 3 gains page 4, PPV and NPV against prevalence: drag
//     along the plot or the slider (or the arrows, 1 % steps) and
//     the big digits follow; the sample's own prevalence is the
//     red marker.  And page 5: the test as a point on the ROC
//     square, with the LR+ and LR- lines.
//   - Module 1 gains page 4, a mosaic plot (ChiTable's csMosaic
//     for the 2x2), tiles coloured by adjusted residual; tap a
//     tile for its O, E and row %.
//   - Module 1, page 3: tap the OR, RR or RD row (or Up/Down) for
//     its worked arithmetic with your own cells and the log-scale
//     CI steps, in an orange box.
//   - Help: three new pages (the grid and soft keys, result pages
//     and taps, PPV vs prevalence and ROC); About says v1.5;
//     footers in the house form.
//   - File-level state (cells, hit boxes, palette constants, the
//     two curve caches) is set in the new es_Init.
//
// What changed in v1.4 (16 Oct 2026) -- the FIX version from the
//   16 Oct fleet review (no touch work yet; line numbers are v1.3's):
//   - Division by zero guarded: es_Risk_Page1 782-790 (Rexp/Runexp
//     with no events in one group), stratified 1564-1565, 1605,
//     1615, 1673-1674 and 1706.  Each prints a teaching line
//     ("undefined: no events in one group") instead of crashing.
//   - es_FmtCI 321-329 now blanks a CI carrying the 999 sentinel
//     (it printed "(0.00-999.00)"); ERR = RR - 1 at 737 and
//     IRR - 1 at 1220 show "undef" instead of 998 (99800%).
//   - EP_chisq, EP_chisq_p, the four EP_*_CI lists and the other
//     conditionally-set EP_* results are reset at module entry
//     (928-1010), so a previous run's values can never show.
//     The same one-line reset in the ELSE branches of 465, 1213,
//     1436, 1573 and 1682.
//   - Page layouts: page 3 of the 2x2 module (590-632) had its
//     Quick Reference drawn under the footer and off the screen,
//     and Cramer's V at y 212; the N line (583) and the ERR line
//     (826-827) ran into the footer.  Re-spaced; everything now
//     ends above y 218.  The Quick Reference is one line.
//   - es_WaitKey 333-343 drains before and after the wait
//     (drain-wait-drain), so the OK press that closed an INPUT
//     can no longer skip the first result page.
//   - es_DrawFooter takes a flag: 1 = paged (prev/next/Esc), 0 =
//     "any key = back" on the five single-page screens (1711,
//     1796, 1856, 1907, 1954).  House footer form and colour.
//   - The eight help MSGBOXes (1962-2081, up to 13 lines where
//     MSGBOX shows about 5) are drawn pages via es_HelpPage
//     (composed on G1, one blit).  Every sentence kept.
//   - Main-menu strings 117-123 lose their circled digits; the
//     Prime numbers the items itself.
//   - Stale "v1.2" strings at 35, 116, 1992 -> v1.4; #pragma line
//     added at the top (every other fleet program has it).
//   - Stratified case-control INPUT (1402-1403) relabelled so that
//     b = Exposed+/Control and c = Exposed-/Case, the same cell
//     layout as modules 1-3; the stratum tables on screen (1469,
//     1471, 1479, 1481) follow.  OR = ad/bc and the M-H sums are
//     unchanged by the swap.
//   - Dead code: unused p1 (926) removed; the unreachable IF N > 0
//     (440-444) simplified; z = 1.96 (152) -> 1.959964.
//   - Palette: house blue band/headers RGB(0,60,200), green
//     RGB(0,140,60), red RGB(255,0,0), grey RGB(90,90,90), dodger
//     blue footer RGB(30,144,255).
//   - The three 8-field INPUT forms (1398-1405, 1533-1543,
//     1643-1653) are each two 4-field forms (stratum 1, then 2);
//     only 6 fields are device-verified.
//   - Gate-driven renames (lint): LOCAL ci -> fc in es_FisherExact
//     (Ci is a built-in), parameter ci -> cil in es_FmtCI, LOCAL E
//     -> moe in the two one-sample size routines (e is reserved);
//     two 7-name LOCAL lines split; != written as <> (both are
//     valid; the checker only knows <>).
// v1.3 (10 Oct 2026): every helper routine now carries the program
//   tag "es_" (FmtNum -> es_FmtNum, Module_Table2x2 -> es_Module_Table2x2).
//   The Prime keeps the routines of ALL programs in one shared
//   pool, so two programs that define the same helper name
//   (NiceNum, fmtp and lngamma were each shared by up to eight
//   programs) can run each other's code.  That is what sent
//   StudRange into a Burr3 screen and forced a reset.
//   Rule: every non-EXPORT routine gets the program's tag.
// v1.2: menus exit cleanly on Esc; displays no longer depend
//   on the Home number format.  Details in the v1.2 notes below.
// ============================================================

// (C) 2026 Roger D Metcalf
// Program concept and rudimentary code by Roger D. Metcalf
// Code corrected, expanded, and vastly improved by Claude Code Fable 5
// For academic use only – do not rely on results for anything important
// Results have not been rigorously verified and/or validated
// ═══════════════════════════════════════════════════════════════════════════
// EpiStats v1.7d - Comprehensive Biostatistics & Epidemiology Calculator
// ═══════════════════════════════════════════════════════════════════════════
// Based on Harvard STAT S-103 "Introduction to Epidemiology and Biostatistics"
// Original programs by Roger Metcalf - Enhanced with modern UI
// ═══════════════════════════════════════════════════════════════════════════
// v1.1 corrections:
//   - Renamed reserved-word variables: OR->oddsR, e->cexp, Re/Ru/Rt->Rexp/Runexp/Rtot
//   - Added es_lngamma() helper (Lanczos); es_FisherExact() now returns a true two-sided p
//   - Fixed es_WaitKey() keycodes (GETKEY: Left=7, Right=8, Esc=4)
//   - Removed unnecessary forward declarations; hoisted LOCALs to top of scope
// v1.2 corrections (29 Sep 2026):
//   - Every CHOOSE now has its index set to 0 immediately before the call.
//     CHOOSE leaves the index unchanged on cancel, so the main menu used to
//     reopen the last module when Esc was pressed (only Exit quit), and the
//     Help menu reopened the last page.  Esc now leaves.
//   - All 59 one-argument STRING(x) calls are now STRING(x,1,0): Standard
//     format chosen by the program.  STRING(x) took the user's Home number
//     format, so the same screen looked different on differently-set
//     calculators.  (Mode 1 ignores the digits argument, so counts and
//     echoed inputs print exactly as entered.)
//   - es_WaitKey: LOCAL key -> kk (house rule: KEY heads user-key definitions).
//   - Includes the July formatting pass already marked 'v1.2' in es_FmtNum.
// ═══════════════════════════════════════════════════════════════════════════

// ─────────────────────────────────────────────────────────────────────────────
// EXPORTED RESULT VARIABLES
// ─────────────────────────────────────────────────────────────────────────────
EXPORT EP_table := {0,0,0,0};
EXPORT EP_N := 0;

EXPORT EP_chisq := 0;
EXPORT EP_chisq_p := 0;
EXPORT EP_chisq_yates := 0;
EXPORT EP_fisher_p := 0;
EXPORT EP_phi := 0;
EXPORT EP_cramersV := 0;

EXPORT EP_sens := 0;
EXPORT EP_spec := 0;
EXPORT EP_ppv := 0;
EXPORT EP_npv := 0;
EXPORT EP_sens_CI := {0,0};
EXPORT EP_spec_CI := {0,0};
EXPORT EP_ppv_CI := {0,0};
EXPORT EP_npv_CI := {0,0};
EXPORT EP_LRpos := 0;
EXPORT EP_LRneg := 0;
EXPORT EP_acc := 0;
EXPORT EP_youden := 0;
EXPORT EP_kappa := 0;

EXPORT EP_risk_exp := 0;
EXPORT EP_risk_unexp := 0;
EXPORT EP_OR := 0;
EXPORT EP_OR_CI := {0,0};
EXPORT EP_RR := 0;
EXPORT EP_RR_CI := {0,0};
EXPORT EP_RD := 0;
EXPORT EP_RD_CI := {0,0};
EXPORT EP_NNT := 0;
EXPORT EP_AFe := 0;
EXPORT EP_AFp := 0;

EXPORT EP_IR_exp := 0;
EXPORT EP_IR_unexp := 0;
EXPORT EP_IRR := 0;
EXPORT EP_IRD := 0;

EXPORT EP_OR_MH := 0;
EXPORT EP_RR_MH := 0;
EXPORT EP_IRR_MH := 0;

// v1.6: the Mantel-Haenszel CIs and tests, and McNemar (module 1)
EXPORT EP_ORMH_CI := {0,0};
EXPORT EP_RRMH_CI := {0,0};
EXPORT EP_IRRMH_CI := {0,0};
EXPORT EP_MH_CHI := 0;
EXPORT EP_MH_P := 0;
EXPORT EP_BD_P := 0;
EXPORT EP_MCN_P := 0;

// v1.7: the exact (Clopper-Pearson) 95 % CIs, {low, high}, and the
// conditional MLE odds ratio with its exact CI (999 = infinite or
// undefined, as EP_OR uses 999)
EXPORT EP_sens_CP := {0,0};
EXPORT EP_spec_CP := {0,0};
EXPORT EP_ppv_CP := {0,0};
EXPORT EP_npv_CP := {0,0};
EXPORT EP_risk_exp_CP := {0,0};
EXPORT EP_risk_unexp_CP := {0,0};
EXPORT EP_OR_CMLE := 0;
EXPORT EP_OR_CMLE_CI := {0,0};

// ─────────────────────────────────────────────────────────────────────────────
// FILE-LEVEL STATE (v1.5)
// A LOCAL written outside every routine is shared by all the routines
// of THIS program and invisible in Home (ChiTable's pattern, device-
// verified).  es_Init gives every one of them its starting value.
// ─────────────────────────────────────────────────────────────────────────────
// ---- the 2x2 being typed in the entry grid ----
LOCAL esCel, esSel, esMod, esM1, esM2, esM3;
//  esCel  the four counts {a, b, c, d}, row by row
//  esSel  the selected cell, 1..4 (red frame)
//  esMod  which module opened the grid (1, 2 or 3): picks the
//         labels, the cell names and the Values form
//  esM1..esM3  each module's last table, kept while the program runs
// ---- the grid geometry, shared by the drawing and the touch code ----
LOCAL esGLX, esGX0, esGCW, esGY0, esGRH, esGHY;
LOCAL esGHH, esGTH, esGTW;
//  label column from esGLX; two data columns from esGX0, esGCW wide;
//  two data rows from esGY0, esGRH tall; column headers at esGHY,
//  esGHH tall; the totals row esGTH tall, the totals column esGTW wide
// ---- touch: where the last tap landed, and the boxes a tap can hit ----
LOCAL esTX, esTY, esHN, esHit, esPick, esTile;
LOCAL esHX1, esHY1, esHX2, esHY2, esHId, esLast;
//  esTX, esTY  the last tap (es_WaitAny), -1 after a key
//  esHN        how many hit boxes the current frame recorded
//  esHX1..esHY2, esHId  the boxes (corners) and what each one is
//  esHit       the id of the box the last tap landed in
//  esPick      the worked-arithmetic box open on module 1 page 3
//              (0 none, 1 OR, 2 RR, 3 RD); esLast the last one shown
//  esTile      the mosaic tile whose numbers are shown (0 none)
// ---- the mosaic's expected counts and adjusted residuals ----
LOCAL esE, esAdj;
// ---- the prevalence page: plot box, slider, curve caches ----
LOCAL esPL, esPR, esPT, esPB, esSLT, esSLB;
LOCAL esPv, esPPY, esNPY;
//  plot box esPL..esPR (272 pixel columns) by esPT..esPB; the slider
//  track from esSLT to esSLB; esPv the prevalence being looked at;
//  esPPY, esNPY the PPV and NPV curves as pixel rows, one per column
// ---- house colours (Lessons Learned section 13) ----
LOCAL esBLUE, esMAG, esRED, esGRN, esORNG, esGRAY;
LOCAL esFOOT, esGRID, esLBLU, esLRED, esLGRN, esLORN;
LOCAL esLGRY, esMBLU, esMRED;
// ---- v1.6: the stratified grids, the three readings, the Fisher bars ----
LOCAL esStr, esMS, esSC, esDrp, esFMsg, esRd;
LOCAL esDir, esQP, esQK, esQLo, esQN, esQSel;
LOCAL esQP0, esQPv, esQOK, esQCut;
//  esStr   the stratum being typed (1 or 2) in the grid
//  esMS    every stratum ever typed, 24 numbers: design 1-3, stratum
//          1-2, cells a-d (kept while the program runs)
//  esSC    the eight cells of the two strata being analysed
//  esDrp   {1 if stratum 1 is dropped, 1 if stratum 2 is} (forest plot)
//  esFMsg  1 = the last tap tried to drop the only stratum left
//  esRd    module 1's reading: 1 cohort, 2 diagnostic, 3 paired
//  esDir   1 while EpiStats2x2(a,b,c,d) runs module 1 from Home
//  esQP .. esQCut  the Fisher bars (es_HyperFill says what each holds)
// ---- v1.7: exact intervals, the conditional odds ratio, the CAS ----
LOCAL esCasOK, esBinOK, esCO, esNL, esNLo, esNHi;
LOCAL esX1, esXCP, esXMP, esXSel, esXCT, esXCK;
LOCAL esDvT, esDvC, esDvOK, esSET, esSEK, esOOK;
LOCAL esOP, esOQ, esOLo, esON, esOMx, esOE0;
//  esCasOK  1 = the last CAS call answered, 0 = it fell back (kit)
//  esBinOK  1 = BINOMIAL_CDF and BINOMIAL passed es_BinCheck at start
//  esCO     module 1's conditional OR: {MLE, CI low, CI high, state}
//           (es_CondOR says what the state means)
//  esNL     the log weights of a' = esNLo..esNHi under OR = 1, one per
//           possible table (es_NCFill), for es_CondOR and page 7
//  esX1     module 1's exact CIs {sens low, high, spec low, high}
//  esXCP    module 3's exact CIs, 8 numbers: sens, spec, PPV, NPV
//  esXMP    the same for mid-P (-1 = not worked out yet)
//  esXSel   the row picked on module 3 page 6 (1..4)
//  esXCT    each row's closed-form text from the CAS; esXCK 0 = not
//           asked yet, 1 = the CAS answered, 2 = it fell back
//  esDvT, esDvC  the CAS's derivative and its check (Help F); esDvOK
//           -1 = not asked yet, 1 = answered, 0 = fell back
//  esSET    the CAS's exact fraction for the table esSEK (Help F)
//  esOOK .. esOE0  the page-7 picture (es_CORFill says what each holds)

// Every file-level value, set once each time the program starts.
es_Init()
BEGIN
    // the palette as hex constants, the way ChiTable keeps it: one
    // place, and no RGB() call inside a drawing loop
    esBLUE := #003CC8h; esMAG := #C800C8h; esRED := #FF0000h;
    esGRN := #008C3Ch; esORNG := #FF4500h; esGRAY := #5A5A5Ah;
    esFOOT := #1E90FFh; esGRID := #E1E4EBh; esLBLU := #EBF2FFh;
    esLRED := #FFEBEBh; esLGRN := #E8F6EBh; esLORN := #FFF0DCh;
    esLGRY := #F0F0F0h; esMBLU := #9EC9F0h; esMRED := #F5A0A0h;
    // grid: labels 4..74, data columns 76..148..220 (72 px), totals
    // column 220..296; headers 32..52, data rows 54..98..142 (44 px),
    // totals row 142..172.  The soft keys own y 218-239.
    esGLX := 4; esGX0 := 76; esGCW := 72; esGY0 := 54; esGRH := 44;
    esGHY := 32; esGHH := 20; esGTH := 30; esGTW := 76;
    // an empty table for each of modules 1-3
    esM1 := {0, 0, 0, 0}; esM2 := {0, 0, 0, 0}; esM3 := {0, 0, 0, 0};
    esCel := {0, 0, 0, 0}; esSel := 1; esMod := 1;
    // up to eight hit boxes per frame
    esHX1 := {0, 0, 0, 0, 0, 0, 0, 0}; esHY1 := {0, 0, 0, 0, 0, 0, 0, 0};
    esHX2 := {0, 0, 0, 0, 0, 0, 0, 0}; esHY2 := {0, 0, 0, 0, 0, 0, 0, 0};
    esHId := {0, 0, 0, 0, 0, 0, 0, 0};
    esHN := 0; esHit := 0; esTX := -1; esTY := -1;
    esPick := 0; esLast := 0; esTile := 0;
    esE := {0, 0, 0, 0}; esAdj := {0, 0, 0, 0};
    // prevalence page: 272 pixel columns from x 34 to 306, plot rows
    // 64..154, slider track 200..208
    esPL := 34; esPR := 306; esPT := 64; esPB := 154;
    esSLT := 200; esSLB := 208;
    esPv := 0.5;
    esPPY := {0}; esNPY := {0};    // filled on entry to the page
    // v1.6
    esStr := 1; esRd := 1; esDir := 0; esFMsg := 0;
    esMS := {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
    esSC := {0, 0, 0, 0, 0, 0, 0, 0};
    esDrp := {0, 0};
    esQP := {0}; esQK := {0}; esQLo := 0; esQN := 0; esQSel := 0;
    esQP0 := 0; esQPv := 0; esQOK := 0; esQCut := 0;
    // v1.7
    esCasOK := 0;
    esBinOK := es_BinCheck();          // the exact intervals' built-ins
    esCO := {0, 0, 0, 3}; esNL := {0}; esNLo := 0; esNHi := 0;
    esX1 := {-1, -1, -1, -1};
    esXCP := {-1, -1, -1, -1, -1, -1, -1, -1};
    esXMP := {-1, -1, -1, -1, -1, -1, -1, -1};
    esXSel := 1; esXCT := {"", "", "", ""}; esXCK := {0, 0, 0, 0};
    esDvT := ""; esDvC := ""; esDvOK := -1;
    esSET := ""; esSEK := {-1, -1, -1, -1};
    esOOK := 0; esOP := {0}; esOQ := {0}; esOLo := 1; esON := 0;
    esOMx := 0; esOE0 := 0;
END;

// ═══════════════════════════════════════════════════════════════════════════
// MAIN PROGRAM
// ═══════════════════════════════════════════════════════════════════════════
EXPORT EpiStats()
BEGIN
    LOCAL choice;

    es_Init();     // v1.5: the file-level state (palette, grid, hit boxes)

    REPEAT
        choice := 0;   // v1.2: CHOOSE leaves its index unchanged on cancel
        // v1.4: no numbers in the item strings -- the Prime numbers
        // its CHOOSE items itself (1-9, then A, B, C).
        CHOOSE(choice, "EpiStats v1.7d ══════════",
            "2×2 Table Analysis",
            "Risk Analysis (RR, OR, NNT)",
            "Sensitivity & Specificity",
            "Incidence Rates",
            "Stratified Analysis (M-H)",
            "Sample Size Calculator",
            "Help & Information",
            "Exit");

        CASE
            IF choice == 1 THEN es_Module_Table2x2(); END;
            IF choice == 2 THEN es_Module_RiskAnalysis(); END;
            IF choice == 3 THEN es_Module_SensSpec(); END;
            IF choice == 4 THEN es_Module_IncidenceRate(); END;
            IF choice == 5 THEN es_Module_Stratified(); END;
            IF choice == 6 THEN es_Module_SampleSize(); END;
            IF choice == 7 THEN es_Module_Help(); END;
            IF choice == 8 THEN RETURN "Results in EP_* variables"; END;
        END;

    UNTIL choice == 0 OR choice == 8;

    RETURN "EpiStats - Results stored in EP_* variables";
END;

// v1.6: the one-line Home call that ConTable2(a, b, c, d) and
// FEP(a, b, c, d) used to offer.  EpiStats2x2(12, 5, 8, 30) puts the
// four counts into module 1 (a = Exposed+/Outcome+, b = Exposed+/
// Outcome-, c = Exposed-/Outcome+, d = Exposed-/Outcome-), skips the
// grid, opens the results at page 1 and, when Esc leaves them,
// returns the two-sided Fisher p as the old programs did (-1 when
// N > 2000, where it is not computed, or when the counts are not
// whole numbers, 0 or more, with N > 0).  It is the only routine
// besides EpiStats that is EXPORTed, so it is the only other entry
// in the catalog.  The parameter names eA..eD do not clash with any
// built-in (ignoring case).
EXPORT EpiStats2x2(eA, eB, eC, eD)
BEGIN
    es_Init();
    IF eA < 0 OR eB < 0 OR eC < 0 OR eD < 0 THEN
        MSGBOX("EpiStats2x2: every count must be 0 or more.");
        RETURN -1;
    END;
    IF FP(eA) <> 0 OR FP(eB) <> 0 OR FP(eC) <> 0 OR FP(eD) <> 0 THEN
        MSGBOX("EpiStats2x2: counts are whole numbers.");
        RETURN -1;
    END;
    IF eA + eB + eC + eD == 0 THEN
        MSGBOX("EpiStats2x2: the table is empty.");
        RETURN -1;
    END;
    esM1 := {eA, eB, eC, eD};
    esDir := 1;                        // module 1 skips its grid
    es_Module_Table2x2();
    esDir := 0;
    RETURN EP_fisher_p;
END;

// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════

// Wilson Score Confidence Interval for Proportions
es_WilsonCI(x, n)
BEGIN
    LOCAL p, z, denom, center, margin;
    LOCAL lo, hi;
    IF n == 0 THEN RETURN {0, 1}; END;
    p := x / n;
    z := 1.959964;   // v1.4: the 97.5th normal percentile in full, as TrendTest uses
    denom := 1 + z^2/n;
    center := p + z^2/(2*n);
    margin := z * SQRT(p*(1-p)/n + z^2/(4*n^2));
    lo := (center - margin) / denom;
    hi := (center + margin) / denom;
    IF lo < 0 THEN lo := 0; END;
    IF hi > 1 THEN hi := 1; END;
    RETURN {lo, hi};
END;

// Odds Ratio 95% CI (Woolf/log method)
es_CalcORCI(a, b, c, d)
BEGIN
    LOCAL lnOR, SE, lo, hi;
    IF a == 0 OR b == 0 OR c == 0 OR d == 0 THEN
        a := a + 0.5; b := b + 0.5;
        c := c + 0.5; d := d + 0.5;
    END;
    lnOR := LN((a*d)/(b*c));
    SE := SQRT(1/a + 1/b + 1/c + 1/d);
    lo := EXP(lnOR - 1.96*SE);
    hi := EXP(lnOR + 1.96*SE);
    RETURN {lo, hi};
END;

// Risk Ratio 95% CI (log method)
es_CalcRRCI(a, n1, c, n2)
BEGIN
    LOCAL p1, p2, lnRR, SE, lo, hi;
    IF n1 == 0 OR n2 == 0 THEN RETURN {0, 999}; END;
    p1 := a / n1;
    p2 := c / n2;
    IF p1 == 0 OR p2 == 0 THEN RETURN {0, 999}; END;
    lnRR := LN(p1/p2);
    SE := SQRT((1-p1)/(n1*p1) + (1-p2)/(n2*p2));
    lo := EXP(lnRR - 1.96*SE);
    hi := EXP(lnRR + 1.96*SE);
    RETURN {lo, hi};
END;

// Risk Difference 95% CI
es_CalcRDCI(a, n1, c, n2)
BEGIN
    LOCAL p1, p2, RD, SE, lo, hi;
    IF n1 == 0 OR n2 == 0 THEN RETURN {-1, 1}; END;
    p1 := a / n1;
    p2 := c / n2;
    RD := p1 - p2;
    SE := SQRT(p1*(1-p1)/n1 + p2*(1-p2)/n2);
    lo := RD - 1.96*SE;
    hi := RD + 1.96*SE;
    IF lo < -1 THEN lo := -1; END;
    IF hi > 1 THEN hi := 1; END;
    RETURN {lo, hi};
END;

// Log-gamma via Lanczos approximation (valid for x > 0).
// HP Prime has no LNGAMMA builtin, so we supply one.
es_lngamma(x)
BEGIN
    LOCAL y, tmp, ser, j, cof;
    cof := {76.18009172947146,-86.50532032941677,24.01409824083091,-1.231739572450155,0.001208650973866179,-0.000005395239384953};
    y := x;
    tmp := x + 5.5;
    tmp := tmp - (x + 0.5)*LN(tmp);
    ser := 1.000000000190015;
    FOR j FROM 1 TO 6 DO
        y := y + 1;
        ser := ser + cof(j)/y;
    END;
    RETURN -tmp + LN(2.5066282746310005*ser/x);
END;

// Fisher's Exact Test — TRUE two-sided p-value.
// Sums hypergeometric point probabilities of all tables (same margins)
// whose probability does not exceed that of the observed table.
es_FisherExact(a, b, c, d)
BEGIN
    LOCAL r1, r2, k1, k2, N;
    LOCAL amin, amax, fa, fb, fc, fd;   // v1.4: the loop cells were ai..di; "ci" is the built-in Ci
    LOCAL lnp0, lnpi, psum, constpart;
    r1 := a + b;
    r2 := c + d;
    k1 := a + c;
    k2 := b + d;
    N  := a + b + c + d;
    IF N > 2000 THEN RETURN -1; END;   // too large; use χ² approximation
    // shared numerator of every log-probability (depends only on margins)
    constpart := es_lngamma(r1+1)+es_lngamma(r2+1)+es_lngamma(k1+1)+es_lngamma(k2+1)-es_lngamma(N+1);
    lnp0 := constpart - es_lngamma(a+1)-es_lngamma(b+1)-es_lngamma(c+1)-es_lngamma(d+1);
    amin := MAX(0, r1 - k2);
    amax := MIN(r1, k1);
    psum := 0;
    FOR fa FROM amin TO amax DO
        fb := r1 - fa;
        fc := k1 - fa;
        fd := k2 - fb;
        lnpi := constpart - es_lngamma(fa+1)-es_lngamma(fb+1)-es_lngamma(fc+1)-es_lngamma(fd+1);
        IF lnpi <= lnp0 + 0.00000001 THEN
            psum := psum + EXP(lnpi);
        END;
    END;
    IF psum > 1 THEN psum := 1; END;
    RETURN psum;
END;

// v1.6: the probability of THIS table alone (the hypergeometric
// point probability), in log space with es_lngamma like
// es_FisherExact, so it never overflows.  FEP and ConTable2 printed
// it beside the two-sided p; it is the height of the red bar on the
// Fisher page.  -1 above N = 2000, as es_FisherExact.
es_FisherPt(ptA, ptB, ptC, ptD)
BEGIN
    LOCAL ptN;
    ptN := ptA + ptB + ptC + ptD;
    IF ptN > 2000 THEN RETURN -1; END;
    RETURN EXP(es_lngamma(ptA+ptB+1) + es_lngamma(ptC+ptD+1) + es_lngamma(ptA+ptC+1) + es_lngamma(ptB+ptD+1) - es_lngamma(ptN+1) - es_lngamma(ptA+1) - es_lngamma(ptB+1) - es_lngamma(ptC+1) - es_lngamma(ptD+1));
END;

// v1.6 (from ConTable2): McNemar's test for PAIRED data -- the same
// people rated twice, or two raters on the same people.  Only the
// discordant pairs b and c carry information:
//     chi-square = (|b - c| - 1)^2 / (b + c),  1 df,
// with Edwards' continuity correction (the -1).  When b = c there is
// nothing to correct and the statistic is 0 (R's mcnemar.test does
// the same; statsmodels would square |b - c| - 1 = -1).  Returns
// {chi-square, p}, or {-1, -1} when b + c = 0 (no discordant pairs).
es_McNemar(mnB, mnC)
BEGIN
    LOCAL mnX;
    IF mnB + mnC == 0 THEN RETURN {-1, -1}; END;
    mnX := 0;
    IF mnB <> mnC THEN mnX := (ABS(mnB - mnC) - 1)^2 / (mnB + mnC); END;
    // v1.7d (E8): the p-value is the UPPER tail of chi-square with 1 df,
    // P(chi-square > x).  1 - CHISQUARE_CDF(1, x) is that number, but the
    // Prime's reals hold 12 digits: once the tail is below about 5E-13
    // (x above about 52) the CDF is exactly 1 and 1 - 1 = 0.  With 1 df
    // the tail is also 2 P(Z < -sqrt(x)), a LOWER normal tail, which
    // keeps its digits however small it is (b = 200, c = 0 gives
    // p = 5.7E-45 here, where 1 - CDF gave 0).  x = 0 gives 2 x 0.5 = 1.
    RETURN {mnX, 2 * NORMALD_CDF(0, 1, -(mnX^0.5))};
END;

// Landis & Koch's words for a kappa (module 3 uses the same scale).
es_KapTxt(ktK)
BEGIN
    IF ktK < 0 THEN RETURN "Poor"; END;
    IF ktK < 0.2 THEN RETURN "Slight"; END;
    IF ktK < 0.4 THEN RETURN "Fair"; END;
    IF ktK < 0.6 THEN RETURN "Moderate"; END;
    IF ktK < 0.8 THEN RETURN "Substantial"; END;
    RETURN "Almost Perfect";
END;

// A "nice" step for axis ticks (Heckbert): the smallest of 1, 2, 5
// times a power of ten that is at least nvV.
es_Nice(nvV)
BEGIN
    LOCAL nvE, nvF;
    IF nvV <= 0 THEN RETURN 1; END;
    nvE := FLOOR(LOG(nvV));
    nvF := nvV / 10^nvE;
    IF nvF <= 1 THEN RETURN 10^nvE; END;
    IF nvF <= 2 THEN RETURN 2 * 10^nvE; END;
    IF nvF <= 5 THEN RETURN 5 * 10^nvE; END;
    RETURN 10 * 10^nvE;
END;

// A p-value as "p = 0.0021", or "p < 0.0001" below what dg decimals
// can show (v1.6; es_FmtNum alone gave "p = < 0.0001").
es_PTxt(pxV, pxD)
BEGIN
    LOCAL pxT;
    pxT := 10^(-pxD);
    IF pxV < pxT THEN RETURN "p < " + STRING(pxT, 2, pxD); END;
    RETURN "p = " + STRING(pxV, 2, pxD);
END;

// "label = value", or "label < value" when the formatted value
// already starts with < or > ("P < 0.000001", not "P = < 0.000001").
es_EqTxt(eqL, eqS)
BEGIN
    IF LEFT(eqS, 1) == "<" OR LEFT(eqS, 1) == ">" THEN RETURN eqL + " " + eqS; END;
    RETURN eqL + " = " + eqS;
END;

// v1.7c: the label of a value drawn apart from it ("IRR = IRe / IRu ="
// on the left, the number on the right): the label's closing " =",
// or no "=" at all when the value already starts with < or > --
// "IRR = IRe / IRu   < 0.001", not "IRR = IRe / IRu =   < 0.001".
// The first character is read only when there is one (DIM first).
es_EqLb(lbL, lbS)
BEGIN
  IF DIM(lbS) > 0 THEN
    IF LEFT(lbS, 1) == "<" OR LEFT(lbS, 1) == ">" THEN RETURN lbL; END;
  END;
  RETURN lbL + " =";
END;

// v1.7c: a value on a line of its own that starts "= " ("= 0.50 per
// 1,000 person-time"): "= " and the value, or the value alone when it
// already starts with < or > ("< 0.01 per 1,000 person-time").
es_EqV(evS)
BEGIN
  IF DIM(evS) > 0 THEN
    IF LEFT(evS, 1) == "<" OR LEFT(evS, 1) == ">" THEN RETURN evS; END;
  END;
  RETURN "= " + evS;
END;

// ─────────────────────────────────────────────────────────────────────────────
// THE CAS KIT (v1.7) -- copied from BRIEF_CAS.md, with xx replaced by this
// program's tag; v1.7a: es_Cas in the call form CASProbe beta 1b found on
// the emulator (BRIEF_CAS_v2), not yet run on a calculator.  The fence (Lessons Learned v7g
// section 9b): CAS text is built from WHOLE NUMBERS only (es_IStr), the CAS
// is called on events (a page opening, a tap), never per frame, its answers
// are TEXT for display and are cached, and every CAS line has a decimal
// fallback and a badge, "exact (CAS)" or "decimal".
// ─────────────────────────────────────────────────────────────────────────────

// A whole number as plain digits: no digit-grouping marks, no
// decimal point, no exponent.  Home's STRING() follows the Home
// number format (PyProbe saw grouping marks and a special minus
// sign), which the CAS cannot read, so CAS text is built only from
// this routine.
es_IStr(esN)
BEGIN
  LOCAL dS, dV, dD;
  dV := ROUND(ABS(esN), 0);
  IF dV == 0 THEN RETURN "0"; END;
  dS := "";
  WHILE dV > 0 DO
    dD := dV MOD 10;
    dS := CHAR(48 + dD) + dS;
    dV := (dV - dD) / 10;
  END;
  IF esN < 0 THEN dS := "-" + dS; END;
  RETURN dS;
END;

// The ONE place this program calls the CAS.  It returns the CAS answer as text,
// or "" if the CAS failed, gave undef, an error or an infinity.
// The call form is the one CASProbe beta 1b found on the HP Prime Virtual
// Calculator (build 2026-09-09, 17 Oct 2026): CAS(s) with s a variable hands
// the text back UNEVALUATED, so the text is built into a literal CAS("...")
// that EXPR reads at run time, and the CAS is asked for string(...) of the
// answer, so a big integer crosses into PPL whole, not as a 12-digit real.
es_Cas(esS)
BEGIN
  LOCAL cR;
  cR := "";
  IFERR
    cR := STRING(EXPR("CAS(" + CHAR(34) + "string(" + esS + ")" + CHAR(34) + ")"));
  THEN
    cR := "";
  END;
  // The answer is a PPL string, and STRING() of it adds one pair of quotes
  // (CASProbe beta 1b T12, emulator): drop that pair.
  IF DIM(cR) >= 2 THEN
    IF LEFT(cR, 1) == CHAR(34) AND MID(cR, DIM(cR), 1) == CHAR(34) THEN
      cR := MID(cR, 2, DIM(cR) - 2);
    END;
  END;
  // undef, an error, or an infinity (CHAR(8734) is the infinity sign; it
  // also catches the Prime's plus-minus infinity, 1/0): no usable answer
  // an empty answer is already a failure; INSTRING("", ...) is "Invalid input" on
  // the Prime (emulator, 17 Oct 2026), so the checks run only on a non-empty answer
  IF DIM(cR) > 0 THEN
    IF INSTRING(cR, "undef") > 0 OR INSTRING(cR, "rror") > 0 OR INSTRING(cR, CHAR(8734)) > 0 THEN cR := ""; END;
  END;
  // (one line added to the kit: the badge flag, set where the answer is judged)
  esCasOK := 0;
  IF DIM(cR) > 0 THEN esCasOK := 1; END;
  RETURN cR;
END;

// Every swA in swS replaced by swB.  CAS answers are shown with the
// student's letters (the CAS works with the tagged name esq, never
// a bare p a student may own) and with × for * (fence rule 7).
es_Swap(swS, swA, swB)
BEGIN
  LOCAL swI, swN, swL, swR;
  swR := ""; swI := 1;
  swN := DIM(swS); swL := DIM(swA);
  WHILE swI <= swN DO
    IF MID(swS, swI, swL) == swA THEN
      swR := swR + swB; swI := swI + swL;
    ELSE
      swR := swR + MID(swS, swI, 1); swI := swI + 1;
    END;
  END;
  RETURN swR;
END;

// A CAS answer as the page shows it: esq -> p, * -> ×.
es_CasShow(shS)
BEGIN
  RETURN es_Swap(es_Swap(shS, "esq", "p"), "*", CHAR(215));
END;

// ─────────────────────────────────────────────────────────────────────────────
// EXACT INTERVALS FOR A PROPORTION (v1.7)
// es_CPB and es_CPInt are the fleet's ONE exact-interval algorithm: the same
// two routines, line for line, are in CICatcher beta 2 as cqCPB and cqCPInt.
// Checked against statsmodels proportion_confint(method='beta') (Clopper-
// Pearson) and an independent root-finder (mid-P) in verify_EpiStats_v1.7.py.
// This is a numeric answer, so it is computed in PPL, not by the CAS
// (BRIEF_CAS rule 8); the CAS only writes the closed form for 0 or n.
// ─────────────────────────────────────────────────────────────────────────────

// Checked once at start: do BINOMIAL_CDF(n, p, k) and BINOMIAL(n, p, k)
// take their arguments in the order (n, p, k) and give the textbook
// values?  The harness found one program calling (n, k, p); this check
// means a wrong order shows "unavailable", never a wrong interval.
//   P(X <= 2) for n = 20, p = 0.1 is 0.676926805...
//   P(X = 2) for n = 19, p = 0.1 is 0.285179807...
es_BinCheck()
BEGIN
  LOCAL bcV, bcW;
  bcV := -1; bcW := -1;
  IFERR
    bcV := BINOMIAL_CDF(20, 0.1, 2);
    bcW := BINOMIAL(19, 0.1, 2);
  THEN
    bcV := -1;
  END;
  IF ABS(bcV - 0.676926805) < 0.000001 AND ABS(bcW - 0.285179807) < 0.000001 THEN RETURN 1; END;
  RETURN 0;
END;

// ONE END of the exact interval for k successes in n trials.
//   cbS  1 = the lower end, 2 = the upper end
//   cbA  alpha/2, the most each tail may hold (0.025 for 95 %)
//   cbM  0 = Clopper-Pearson, 1 = mid-P
//   cbG  a starting guess in (0, 1), or anything else for none
// Clopper-Pearson keeps every p under which your count is in NEITHER
// tail of Binomial(n, p) that holds alpha/2 or less:
//   lower end: P(X >= k | p) = 1 - BINOMIAL_CDF(n, p, k - 1) = alpha/2
//   upper end: P(X <= k | p) =     BINOMIAL_CDF(n, p, k)     = alpha/2
// (BINOMIAL_CDF(n, p, k) = P(X <= k), the (n, p, k) order, Lessons
// Learned section 3).  mid-P counts only HALF of P(X = k) in each
// tail, so its interval is a little shorter.
// No search is needed at the edges: k = 0 has lower end 0 and k = n
// upper end 1, and then the other end has a closed form, because the
// tail is one term: (1 - p)^n = alpha/2 gives p = 1 - (alpha/2)^(1/n),
// p^n = alpha/2 gives p = (alpha/2)^(1/n) (mid-P: 2 alpha/2 for alpha/2).
// Otherwise: h(p) below RISES with p and is 0 at the end wanted, so
// [0, 1] always brackets it.  Each pass halves the bracket -- unless
// Newton's step, which needs the slope, lands inside it; the slope
// comes from the binomial itself,
//   d/dp P(X <= k) = -n BINOMIAL(n - 1, p, k),
// so a pass costs one BINOMIAL_CDF and one BINOMIAL (two of each for
// mid-P).  About five passes give ten significant digits, where
// halving alone would need about 33.  The stop is relative (1E-9 of
// p), which the Prime's 12-digit arithmetic can always reach.
es_CPB(cbK, cbN, cbA, cbS, cbM, cbG)
BEGIN
  LOCAL cbLo, cbHi, cbP, cbH, cbD, cbQ;
  LOCAL cbC0, cbC1, cbGo, cbI;
  IF cbS == 1 AND cbK <= 0 THEN RETURN 0; END;
  IF cbS == 2 AND cbK >= cbN THEN RETURN 1; END;
  IF cbS == 1 AND cbK >= cbN THEN RETURN ((1 + cbM) * cbA)^(1 / cbN); END;
  IF cbS == 2 AND cbK <= 0 THEN RETURN 1 - ((1 + cbM) * cbA)^(1 / cbN); END;
  cbLo := 0; cbHi := 1;
  cbP := cbG;
  IF cbP <= 0 OR cbP >= 1 THEN cbP := cbK / cbN; END;
  cbGo := 1; cbI := 0;
  WHILE cbGo == 1 DO
    cbI := cbI + 1;
    // the two CDF values this end needs
    cbC0 := 0; cbC1 := 0;
    IF cbS == 2 OR cbM == 1 THEN cbC0 := BINOMIAL_CDF(cbN, cbP, cbK); END;
    IF cbS == 1 OR cbM == 1 THEN cbC1 := BINOMIAL_CDF(cbN, cbP, cbK - 1); END;
    // h(p), rising with p, and its slope cbD
    IF cbM == 0 THEN
      IF cbS == 1 THEN
        cbH := (1 - cbC1) - cbA;                     // P(X >= k) - alpha/2
        cbD := cbN * BINOMIAL(cbN - 1, cbP, cbK - 1);
      ELSE
        cbH := cbA - cbC0;                           // alpha/2 - P(X <= k)
        cbD := cbN * BINOMIAL(cbN - 1, cbP, cbK);
      END;
    ELSE
      cbD := cbN * (BINOMIAL(cbN - 1, cbP, cbK) + BINOMIAL(cbN - 1, cbP, cbK - 1)) / 2;
      IF cbS == 1 THEN
        cbH := (1 - (cbC0 + cbC1) / 2) - cbA;         // P(X > k) + P(X = k)/2 - alpha/2
      ELSE
        cbH := cbA - (cbC0 + cbC1) / 2;               // alpha/2 - P(X < k) - P(X = k)/2
      END;
    END;
    IF cbH == 0 THEN
      cbGo := 0;                                     // exactly on it
    ELSE
      // the end wanted lies between cbLo and cbHi
      IF cbH < 0 THEN cbLo := cbP; ELSE cbHi := cbP; END;
      cbQ := -1;
      IF cbD > 0 THEN cbQ := cbP - cbH / cbD; END;   // Newton's step
      IF cbD > 0 AND ABS(cbQ - cbP) <= 0.000000001 * cbP THEN
        cbGo := 0;                                   // the step is tiny: done
      ELSE
        // a Newton step outside the bracket is replaced by halving it
        IF cbQ <= cbLo OR cbQ >= cbHi THEN cbQ := (cbLo + cbHi) / 2; END;
        IF cbHi - cbLo <= 0.000000001 * cbP THEN cbGo := 0; END;
      END;
      IF cbI >= 100 THEN cbGo := 0; END;             // a guard; never reached in tests
      cbP := cbQ;
    END;
  END;
  RETURN cbP;
END;

// The whole exact interval {lower, upper} for k of n, each tail
// alpha/2 = xpA, xpM 0 = Clopper-Pearson, 1 = mid-P.  The Wilson
// interval at the same level is the starting guess for each end.
es_CPInt(xpK, xpN, xpA, xpM)
BEGIN
  LOCAL wiZ, wiP, wiD, wiC, wiH, wiR;
  IF xpN <= 0 THEN RETURN {0, 1}; END;
  wiZ := NORMALD_ICDF(0, 1, 1 - xpA);
  wiP := xpK / xpN;
  wiD := 1 + wiZ^2 / xpN;
  wiC := (wiP + wiZ^2 / (2 * xpN)) / wiD;
  wiH := wiZ * (wiP * (1 - wiP) / xpN + wiZ^2 / (4 * xpN^2))^0.5 / wiD;
  wiR := {es_CPB(xpK, xpN, xpA, 1, xpM, wiC - wiH), es_CPB(xpK, xpN, xpA, 2, xpM, wiC + wiH)};
  RETURN wiR;
END;

// A positive number to about 3 significant figures (8.56, 41.3,
// 0.00123, 1234): odds ratios and their CIs span many decades.
es_SigTxt(sfV)
BEGIN
  LOCAL sfD;
  IF sfV <= 0 THEN RETURN "0"; END;
  IF sfV < 0.000001 THEN RETURN "< 0.000001"; END;
  sfD := MAX(0, MIN(6, 2 - FLOOR(LOG(sfV))));
  RETURN STRING(sfV, 2, sfD);
END;

// ─────────────────────────────────────────────────────────────────────────────
// THE CONDITIONAL ODDS RATIO (v1.7)
// With all four totals of the 2×2 fixed (Fisher's view), the table is decided
// by a alone, and under a true odds ratio psi
//     P(a' | psi)  is proportional to  C(a+c, a') C(b+d, a+b-a') psi^a'
// (Fisher's noncentral hypergeometric; psi = 1 is the plain hypergeometric
// of the Fisher bars on page 4).  The conditional MLE is the psi whose MEAN
// of a' is your a; the exact 95 % CI holds every psi under which your a is
// in neither 2.5 % tail.  These are R's fisher.test estimate and CI, and
// scipy.stats.contingency.odds_ratio(kind='conditional').  All three are
// numbers, so PPL computes them (BRIEF_CAS rule 8).
// ─────────────────────────────────────────────────────────────────────────────

// The log weights of every possible a' under psi = 1, relative to the
// smallest a' (esNLo), by the ratio of neighbouring terms
//   P(a'+1)/P(a') = (c1 - a')(r1 - a') / ((a' + 1)(d - a + a' + 1)),
// one LN per term: no factorials, no overflow, no LNGAMMA rounding.
// Fills esNL (one entry per a' from esNLo to esNHi).
es_NCFill(nfA, nfB, nfC, nfD)
BEGIN
  LOCAL nfR1, nfC1, nfD0, nfI, nfS;
  nfR1 := nfA + nfB; nfC1 := nfA + nfC;
  nfD0 := nfD - nfA;                       // N - r1 - c1
  esNLo := MAX(0, -nfD0);
  esNHi := MIN(nfR1, nfC1);
  nfS := esNHi - esNLo + 1;
  esNL := MAKELIST(0, K, 1, nfS, 1);
  FOR nfI FROM esNLo TO esNHi - 1 DO
    esNL(nfI - esNLo + 2) := esNL(nfI - esNLo + 1) + LN((nfC1 - nfI) * (nfR1 - nfI) / ((nfI + 1) * (nfD0 + nfI + 1)));
  END;
END;

// The distribution of a' at ln(psi) = evT, from esNL: returns
//   {mean, variance, P(a' <= a), mean of a' given a' <= a,
//    P(a' >= a), mean of a' given a' >= a}
// The log weights esNL(j) + j evT are concave in j (the distribution
// is log-concave), so the peak is found by halving on the slope, and
// the sums walk out from it in both directions and stop where a term
// falls below e^-46 (about 1E-20) of the peak: every later term is
// smaller still.  So a table with N = 2000 costs a few hundred terms,
// not a thousand, and nothing can overflow.
es_NCEval(evT, evA)
BEGIN
  LOCAL evLo, evHi, evMd, evM, evJ, evG;
  LOCAL evW, evZ, evS1, evS2, evPL, evL1;
  LOCAL evPH, evH1, evAr, evDj, evR, evE;
  LOCAL evV, evDir, evTop;
  evTop := esNHi - esNLo;                  // the last relative index
  // the peak: the first j at which the next weight is no larger
  evLo := 0; evHi := evTop;
  WHILE evLo < evHi DO
    evMd := IP((evLo + evHi) / 2);
    IF esNL(evMd + 2) - esNL(evMd + 1) + evT > 0 THEN evLo := evMd + 1; ELSE evHi := evMd; END;
  END;
  evM := evLo;
  evAr := evA - esNLo;                     // your a, as a relative index
  evZ := 0; evS1 := 0; evS2 := 0; evPL := 0; evL1 := 0; evPH := 0; evH1 := 0;
  FOR evDir FROM 1 TO 2 DO
    // direction 1 walks down from the peak, 2 walks up from just above it
    IF evDir == 1 THEN evJ := evM; ELSE evJ := evM + 1; END;
    evG := -100;
    IF evJ <= evTop THEN evG := (esNL(evJ + 1) - esNL(evM + 1)) + (evJ - evM) * evT; END;
    WHILE evG > -46 DO
      evW := EXP(evG);
      evDj := evJ - evM;                   // moments about the peak: no cancellation
      evZ := evZ + evW; evS1 := evS1 + evDj * evW; evS2 := evS2 + evDj * evDj * evW;
      IF evJ <= evAr THEN evPL := evPL + evW; evL1 := evL1 + evDj * evW; END;
      IF evJ >= evAr THEN evPH := evPH + evW; evH1 := evH1 + evDj * evW; END;
      IF evDir == 1 THEN evJ := evJ - 1; ELSE evJ := evJ + 1; END;
      IF evJ < 0 OR evJ > evTop THEN
        evG := -100;
      ELSE
        evG := (esNL(evJ + 1) - esNL(evM + 1)) + (evJ - evM) * evT;
      END;
    END;
  END;
  evE := evS1 / evZ;
  evV := MAX(0, evS2 / evZ - evE^2);
  evR := {esNLo + evM + evE, evV, evPL / evZ, 0, evPH / evZ, 0};
  IF evPL > 0 THEN evR(4) := esNLo + evM + evL1 / evPL; END;
  IF evPH > 0 THEN evR(6) := esNLo + evM + evH1 / evPH; END;
  RETURN evR;
END;

// Solve for t = ln(psi):
//   nrK 1  mean of a' = a                    (the MLE)
//   nrK 2  P(a' <= a) = alpha/2 = nrA2       (the upper end of the CI)
//   nrK 3  P(a' >= a) = alpha/2              (the lower end)
// Each f below RISES with t.  The bracket starts at t = -60 .. 60
// (psi from 1E-26 to 1E26); each pass halves it unless Newton's step
// lands inside.  The slopes come with the sums: d/dt mean = variance,
// d/dt ln P(a' <= a) = (mean given a' <= a) - mean, and the same for
// a' >= a.  Three to five passes in every table tested.
es_NCRoot(nrK, nrA, nrA2, nrT0)
BEGIN
  LOCAL nrLo, nrHi, nrT, nrV, nrF, nrD;
  LOCAL nrQ, nrI, nrGo;
  nrLo := -60; nrHi := 60;
  nrT := MIN(59, MAX(-59, nrT0));
  nrGo := 1; nrI := 0;
  WHILE nrGo == 1 DO
    nrI := nrI + 1;
    nrV := es_NCEval(nrT, nrA);
    nrF := 0; nrD := 0;
    IF nrK == 1 THEN nrF := nrV(1) - nrA; nrD := nrV(2); END;
    IF nrK == 2 THEN
      IF nrV(3) > 0 THEN
        nrF := LN(nrA2) - LN(nrV(3)); nrD := nrV(1) - nrV(4);
      ELSE
        nrF := 1000;                         // all the mass is above a: t is too big
      END;
    END;
    IF nrK == 3 THEN
      IF nrV(5) > 0 THEN
        nrF := LN(nrV(5)) - LN(nrA2); nrD := nrV(6) - nrV(1);
      ELSE
        nrF := -1000;                        // all the mass is below a: t is too small
      END;
    END;
    IF nrF == 0 THEN
      nrGo := 0;
    ELSE
      IF nrF < 0 THEN nrLo := nrT; ELSE nrHi := nrT; END;
      nrQ := nrLo - 1;
      IF nrD > 0 THEN nrQ := nrT - nrF / nrD; END;
      IF nrD > 0 AND ABS(nrQ - nrT) < 0.000000001 THEN
        nrGo := 0;                           // Newton's step is tiny: done
      ELSE
        IF nrQ <= nrLo OR nrQ >= nrHi THEN nrQ := (nrLo + nrHi) / 2; END;
        IF nrHi - nrLo < 0.000000001 THEN nrGo := 0; END;
      END;
      IF nrI >= 100 THEN nrGo := 0; END;     // a guard; never reached in tests
      nrT := nrQ;
    END;
  END;
  RETURN nrT;
END;

// The conditional MLE odds ratio and its exact 95 % CI for one 2×2:
// {MLE, CI low, CI high, state}
//   state 0  a lies strictly inside its range: all three solved
//   state 1  a is as SMALL as the totals allow: MLE 0, CI from 0
//   state 2  a is as LARGE as the totals allow: MLE and CI top infinite
//   state 3  a row or column total is 0: undefined
//   state 4  N > 2000: not computed (the same cap as Fisher's test)
// It also leaves esNL, esNLo, esNHi for page 7's picture.
es_CondOR(coA, coB, coC, coD)
BEGIN
  LOCAL coN, coH, coLg, coSd, coM, coLo;
  LOCAL coHi, coSt;
  coN := coA + coB + coC + coD;
  IF coN > 2000 THEN RETURN {0, 0, 0, 4}; END;
  IF coA + coB == 0 OR coC + coD == 0 OR coA + coC == 0 OR coB + coD == 0 THEN RETURN {0, 0, 0, 3}; END;
  es_NCFill(coA, coB, coC, coD);
  // starting points: ln of the sample OR and its Woolf limits (0.5
  // added to every cell when one is 0, as es_CalcORCI does)
  coH := 0;
  IF coA * coB * coC * coD == 0 THEN coH := 0.5; END;
  // (coLg, coSd: "coL" and "coS" would be COL and COS, ignoring case)
  coLg := LN((coA + coH) * (coD + coH) / ((coB + coH) * (coC + coH)));
  coSd := (1 / (coA + coH) + 1 / (coB + coH) + 1 / (coC + coH) + 1 / (coD + coH))^0.5;
  coSt := 0; coM := 0; coLo := 0; coHi := 0;
  IF coA == esNLo THEN coSt := 1; END;
  IF coA == esNHi THEN coSt := 2; END;
  IF coSt == 0 THEN coM := EXP(es_NCRoot(1, coA, 0.025, coLg)); END;
  IF coA < esNHi THEN coHi := EXP(es_NCRoot(2, coA, 0.025, coLg + 1.959964 * coSd)); END;
  IF coA > esNLo THEN coLo := EXP(es_NCRoot(3, coA, 0.025, coLg - 1.959964 * coSd)); END;
  RETURN {coM, coLo, coHi, coSt};
END;

// The conditional OR of esCO as text: "8.56  (2.09-41.3)".
es_CORTxt()
BEGIN
  IF esCO(4) == 4 THEN RETURN "not computed (N > 2000)"; END;
  IF esCO(4) == 3 THEN RETURN "undefined (a total is 0)"; END;
  IF esCO(4) == 1 THEN RETURN "0  (0-" + es_SigTxt(esCO(3)) + ")"; END;
  IF esCO(4) == 2 THEN RETURN "infinite  (" + es_SigTxt(esCO(2)) + "-infinity)"; END;
  RETURN es_SigTxt(esCO(1)) + "  (" + es_SigTxt(esCO(2)) + "-" + es_SigTxt(esCO(3)) + ")";
END;

// Draw Page Header
// v1.4: house blue band RGB(0,60,200) (was navy 0,70,140).
// v1.5: every page now STARTS here.  DIMGROB_P gives a fresh white
// G1 (the off-screen picture); everything after this draws on G1,
// and es_Foot shows the finished page with one BLIT_P.  Drawing
// straight onto the screen (G0), as v1.4 did, flashed white and
// built each page up line by line in front of the class.  The hit
// boxes of the previous frame are forgotten here, too.
es_DrawHeader(title, pageInfo)
BEGIN
    DIMGROB_P(G1, 320, 240, #FFFFFFh);
    RECT_P(G1, 0, 0, 319, 239, RGB(245,245,255), RGB(245,245,255));
    RECT_P(G1, 0, 0, 319, 24, RGB(0,60,200), RGB(0,60,200));
    TEXTOUT_P(title, G1, 8, 4, 3, RGB(255,255,255));
    TEXTOUT_P(pageInfo, G1, 250, 6, 2, RGB(180,200,255));
    esHN := 0;                       // no tappable boxes on this frame yet
END;

// The footer line, then the ONE blit that puts the finished page on
// the screen (v1.5).  ftTxt is the key legend in the house form:
// comma-separated, spaces round "=", dodger blue.  The width 306
// clips a long legend at the right edge instead of running off it.
es_Foot(ftTxt)
BEGIN
    TEXTOUT_P(ftTxt, G1, 8, 224, 1, esFOOT, 306);
    BLIT_P(G0, 0, 0, G1);
END;

// Draw Page Footer
// v1.4: the footer must say what the keys actually do (Lessons
// Learned section 7).  fPaged = 1 on a multi-page result (arrows
// move between pages); fPaged = 0 on a single-page screen, where
// any key goes back.  House form: comma-separated, spaces round
// "=", dodger blue RGB(30,144,255), no dark band.  The arrows are
// CHAR(8592) / CHAR(8594), the glyphs ChiTable has run on both
// calculators.
// v1.5: a tap works too (left half = back, right half = on), and
// the footer ends the frame with the blit (es_Foot).
es_DrawFooter(fPaged)
BEGIN
    IF fPaged == 1 THEN
        es_Foot(CHAR(8592) + "/" + CHAR(8594) + " or tap left/right = page, Esc = menu");
    ELSE
        es_Foot("any key or tap = back");
    END;
END;

// Format number
// v1.2: STRING(ROUND(x,d)) is mode 1 = Standard, which DROPS trailing zeros
// regardless of the digits argument (p = 0.05000 printed as "0.05", 1.0 as "1"),
// so the decimal width drifted row to row.  Now fixed-decimal STRING(x,2,dec).
// The small-value floor was also hard-coded at 0.0001 no matter what `dec` was
// asked for -- es_FmtNum(fisher_p, 6) claimed "<0.0001" for a p it could have shown
// in full.  The floor is now 10^(-dec), so it always tracks the requested width.
// Sign-aware: a tiny NEGATIVE value (risk differences go negative) reported
// "<0.0001", which is false; it now reads "> -0.0001".  Exact 0 prints as 0.0000
// rather than being swallowed by the floor.
//
// The `x == -1 -> "N/A"` branch was REMOVED in v1.2.  es_FisherExact() returns -1
// for "N > 2000, not computed", but every display site already guards that at
// the source (`IF fisher_p >= 0 THEN ...` at lines ~500 and ~1086), so the
// branch was unreachable for its intended purpose -- while silently corrupting
// any real measurement that happens to equal -1.  Three bounded quantities
// routed through es_FmtNum can legitimately be exactly -1:
//     phi        = -1  perfect negative 2x2 association
//     kappa      = -1  complete disagreement between raters
//     effectSize = -1  Cohen's d of -1, an ordinary large negative effect
// All three printed "N/A" instead of their value.  (phi^2 and pearsonR are
// non-negative by construction -- pearsonR := SQRT(chisq/N) -- so they were
// never at risk.)  Keep testing the sentinel by RANGE at the call site, never
// by equality here: a probability is never negative, so `>= 0` is unambiguous
// where `!= -1` is a collision waiting to happen.
es_FmtNum(x, dec)
BEGIN
    LOCAL thr;
    IF x == 999 OR x == 9999 THEN RETURN "undef"; END;
    IF ABS(x) > 9999 THEN RETURN STRING(x, 1, 0); END;
    thr := 10^(-dec);
    IF x <> 0 AND ABS(x) < thr THEN
        IF x > 0 THEN RETURN "< " + STRING(thr, 2, dec); END;
        RETURN "> -" + STRING(thr, 2, dec);
    END;
    RETURN STRING(x, 2, dec);
END;

// Format percentage
// v1.7b: ROUND before STRING.  On the Prime STRING(x, 2, d) prints in
// scientific form when 0 < |x| < 10^-d ("5.0ᴇ-2" for 0.05 at one
// decimal; emulator, 17 Oct 2026); rounded first, x is 0 or at least
// 10^-d, so the fixed form always holds (0.0 % for 0.04 %).
es_FmtPct(x)
BEGIN
    IF x == 999 OR x == 9999 THEN RETURN "undef"; END;
    RETURN STRING(ROUND(x*100, 1), 2, 1) + "%";
END;

// Format CI
// v1.4: a CI is blanked when it is the "not computed" default {0,0}
// OR when either bound is the 999 sentinel that es_CalcRRCI returns
// (and that modules 1 and 2 store) for an undefined ratio.  It used
// to print "(0.00-999.00)" beside an "undef" RR.  The parameter was
// `ci`, which is the built-in Ci when case is ignored.
// v1.7b: each bound is rounded first (see es_FmtPct): an OR CI from
// 0.0027 printed "2.67ᴇ-3" on the Prime's rule.
es_FmtCI(cil, asPct)
BEGIN
    IF cil(1) == 0 AND cil(2) == 0 THEN RETURN ""; END;
    IF cil(1) == 999 OR cil(2) == 999 THEN RETURN ""; END;
    IF asPct THEN
        RETURN "(" + STRING(ROUND(cil(1)*100, 1), 2, 1) + "-" + STRING(ROUND(cil(2)*100, 1), 2, 1) + "%)";
    ELSE
        RETURN "(" + STRING(ROUND(cil(1), 2), 2, 2) + "-" + STRING(ROUND(cil(2), 2), 2, 2) + ")";
    END;
END;

// ─────────────────────────────────────────────────────────────────────────────
// TOUCH AND KEYS (v1.5) -- the house touch kit, copied from ChiTable
// beta 1k (csDrain, csWaitAny, csResults), which ran on a G2 and a G1.
// ─────────────────────────────────────────────────────────────────────────────

// Wait for the finger to lift -- but never for more than a second.
// MOUSE(1) is -1 while nothing touches the screen.  Without the time
// limit a stuck or phantom touch would hold the program here for
// ever, and the only way out would be a reset.  TICKS is the clock
// in milliseconds.
es_Drain()
BEGIN
    LOCAL drT;
    drT := TICKS;
    REPEAT UNTIL MOUSE(1) == -1 OR TICKS - drT > 1000;
END;

// Drain, wait for a key OR a tap, drain.  Returns the key code, or
// -1 for a tap; the tap's x and y are left in esTX, esTY.
// A GETKEY drain does not drain touch (Lessons Learned section 7),
// so both are drained, before the wait and after it: the OK press
// or the finger lift that closed the last screen cannot close this
// one.  MOUSE gives one list per finger; m(1) is the first finger,
// {x, y, x0, y0, type} while it is down and {} when it is up, so x
// and y are read only after SIZE(m(1)) > 0.
es_WaitAny()
BEGIN
    LOCAL wk, wm, wm1;
    REPEAT wk := GETKEY; UNTIL wk == -1;     // drain stale keys
    es_Drain();                              // and a finger still down
    REPEAT
        wk := GETKEY;
        wm := MOUSE;
        wm1 := wm(1);
    UNTIL wk > -1 OR SIZE(wm1) > 0;
    esTX := -1; esTY := -1;
    IF SIZE(wm1) > 0 THEN esTX := wm1(1); esTY := wm1(2); wk := -1; END;
    es_Drain();                              // wait for the finger to lift
    REPEAT UNTIL GETKEY == -1;               // and drain the key
    RETURN wk;
END;

// Remember a tappable box drawn on the current frame (v1.5).  The
// drawing code records the box, the tap handler (es_WaitKey) scans
// the list, so the two can never disagree about where a row or a
// tile is (ChiTable's hit boxes).  Up to eight per frame;
// es_DrawHeader forgets them all.
es_AddHit(ahId, ahX1, ahY1, ahX2, ahY2)
BEGIN
    IF esHN >= 8 THEN RETURN 0; END;
    esHN := esHN + 1;
    esHX1(esHN) := ahX1; esHY1(esHN) := ahY1;
    esHX2(esHN) := ahX2; esHY2(esHN) := ahY2;
    esHId(esHN) := ahId;
    RETURN esHN;
END;

// Wait for a key or a tap and say what it means on a result page.
// HP Prime GETKEY codes: Esc=4, Left=7, Right=8, Up=2, Down=12.
// v1.4: drain, wait, drain.  v1.5: a tap counts as well (through
// es_WaitAny).  Returns
//    0  Esc: back to the menu
//   -1  Left arrow           1  Right arrow, Enter or any other key
//    4  Up arrow             5  Down arrow (pages that use them;
//                               es_TurnPage treats them as "next")
//   -2  a tap on the left half of the screen
//    2  a tap on the right half
//    3  a tap inside a box recorded with es_AddHit; esHit says which
es_WaitKey()
BEGIN
    LOCAL kk, wkI;
    kk := es_WaitAny();
    IF kk == -1 THEN                          // a tap, at esTX, esTY
        FOR wkI FROM 1 TO esHN DO
            IF esTX >= esHX1(wkI) AND esTX <= esHX2(wkI) AND esTY >= esHY1(wkI) AND esTY <= esHY2(wkI) THEN
                esHit := esHId(wkI);
                RETURN 3;
            END;
        END;
        IF esTX < 160 THEN RETURN -2; END;    // left half: back a page
        RETURN 2;                             // right half: on a page
    END;
    IF kk == 4 THEN RETURN 0; END;    // Esc  -> exit
    IF kk == 7 THEN RETURN -1; END;   // Left -> previous page
    IF kk == 8 THEN RETURN 1; END;    // Right-> next page
    IF kk == 2 THEN RETURN 4; END;    // Up
    IF kk == 12 THEN RETURN 5; END;   // Down
    RETURN 1;                          // any other key -> next
END;

// The page to show next (v1.5), from es_WaitKey's answer.  The
// arrows go round as in v1.4 (past the last page is page 1); a tap
// never goes past either end -- the first page stays on a left tap
// and the LAST page stays on a right tap, so a tap can never drop
// the class back into the menu.  Only Esc leaves.
es_TurnPage(tpPg, tpAct, tpN)
BEGIN
    IF tpAct == -2 THEN RETURN MAX(1, tpPg - 1); END;
    IF tpAct == 2 THEN RETURN MIN(tpN, tpPg + 1); END;
    IF tpAct == -1 THEN
        IF tpPg <= 1 THEN RETURN tpN; END;
        RETURN tpPg - 1;
    END;
    IF tpAct == 1 OR tpAct == 4 OR tpAct == 5 THEN
        IF tpPg >= tpN THEN RETURN 1; END;
        RETURN tpPg + 1;
    END;
    RETURN tpPg;                       // a hit box: stay on this page
END;

// A number with its sign, "+3.62" or "-1.05" (residuals).
// v1.7b: rounded first, so a residual near 0 reads +0.00, not 4.00ᴇ-3.
es_Sgn(sgV, sgD)
BEGIN
    IF sgV >= 0 THEN RETURN "+" + STRING(ROUND(sgV, sgD), 2, sgD); END;
    RETURN STRING(ROUND(sgV, sgD), 2, sgD);
END;

// A count as text: a whole number plainly, a Haldane-corrected
// half (12.5) with its one decimal.
es_Cnt(cnV)
BEGIN
    IF FP(cnV) == 0 THEN RETURN STRING(cnV, 2, 0); END;
    // v1.7d (E11): a person-time typed in the rates grid can be below
    // 0.1, and STRING(x, 2, 1) prints the Prime's SCIENTIFIC form for
    // 0 < x < 0.1 ("5.0E-2" for 0.05).  Rounding to one decimal would
    // lose the value (0.05 -> 0.1), so it gets three significant
    // figures instead (es_SigTxt: "0.0500").  Counts are never negative
    // here (the grid refuses a negative), so 0 < cnV < 0.1 is the case.
    IF cnV > 0 AND cnV < 0.1 THEN RETURN es_SigTxt(cnV); END;
    RETURN STRING(cnV, 2, 1);
END;

// A rate as text (cases / person-time), "-" without person-time (v1.6).
es_RateTx(rtC, rtT)
BEGIN
    IF rtT <= 0 THEN RETURN "-"; END;
    RETURN es_FmtNum(rtC / rtT, 4);
END;

// Help page (v1.4).  MSGBOX shows about five lines, so the help
// texts (7 to 13 lines each) were never fully visible.  This draws
// a page instead: compose everything on G1, then one BLIT_P to G0,
// so the screen never shows a half-drawn page.  hpLines is a list
// of up to 12 strings, drawn in the small font 14 px apart from
// y = 30 (the 12th line ends near y 196, well above the footer).
// v1.5: any key OR a tap goes back (es_WaitAny); returns the key
// code, -1 for a tap.
es_HelpPage(hpTitle, hpLines)
BEGIN
    LOCAL hpI, hpY;
    DIMGROB_P(G1, 320, 240, #FFFFFFh);
    RECT_P(G1, 0, 0, 319, 239, RGB(245,245,255), RGB(245,245,255));
    RECT_P(G1, 0, 0, 319, 24, RGB(0,60,200), RGB(0,60,200));     // title band
    TEXTOUT_P(hpTitle, G1, 8, 4, 3, RGB(255,255,255));
    FOR hpI FROM 1 TO SIZE(hpLines) DO
        hpY := 30 + 14 * (hpI - 1);
        // an empty string is a blank line; skip the call (StudRange draws
        // "" without a guard and ran; ChiTable guards with DIM -- both fine)
        IF DIM(hpLines(hpI)) > 0 THEN
            TEXTOUT_P(hpLines(hpI), G1, 12, hpY, 1, RGB(0,0,0));
        END;
    END;
    TEXTOUT_P("any key or tap = back", G1, 8, 224, 1, RGB(30,144,255));
    BLIT_P(G0, 0, 0, G1);
    RETURN es_WaitAny();                 // drain, wait for a key or tap, drain
END;

// ═══════════════════════════════════════════════════════════════════════════
// THE 2×2 ENTRY GRID (v1.5), ported from ChiTable's csGrid / csEntry
// Modules 1, 2 and 3 type their table here instead of in an INPUT
// form.  The four counts live in esCel = {a, b, c, d}, row by row:
//            col 1   col 2
//   row 1      a       b
//   row 2      c       d
// ═══════════════════════════════════════════════════════════════════════════

// The name printed in the corner of a cell: a b c d, and in the
// diagnostic module also what the cell means (TP = true positive,
// FP = false positive, FN = false negative, TN = true negative).
es_CellName(cnI)
BEGIN
    LOCAL cnL;
    IF esMod == 3 THEN
        cnL := {"a  TP", "b  FP", "c  FN", "d  TN"};
    ELSE
        // v1.6: the stratified rates grid holds cases and person-time
        IF esMod == 7 THEN
            cnL := {"cases", "PT", "cases", "PT"};
        ELSE
            cnL := {"a", "b", "c", "d"};
        END;
    END;
    RETURN cnL(cnI);
END;

// The Values soft key (or the + key): all four counts in ONE form,
// for anyone who would rather type than tap.  Each module gets the
// very form v1.4 used, prefilled with what the grid holds.  INPUT
// needs variable NAMES, so the counts pass through avA..avD.
// Returns 1 if the form was accepted, 0 if it was cancelled or held
// a negative count.
es_AskAll()
BEGIN
    LOCAL avA, avB, avC, avD, avOK, avL;
    LOCAL avI, avR, avS1, avS2, avS3, avS4;
    LOCAL avSb;
    avA := esCel(1); avB := esCel(2); avC := esCel(3); avD := esCel(4);
    avOK := 0;
    CASE
        IF esMod == 1 THEN
            avOK := INPUT(
                {avA, avB, avC, avD},
                "2×2 Contingency Table",
                {"Cell a:", "Cell b:", "Cell c:", "Cell d:"},
                {"Top-left (Exposed+/Outcome+)",
                 "Top-right (Exposed+/Outcome-)",
                 "Bottom-left (Exposed-/Outcome+)",
                 "Bottom-right (Exposed-/Outcome-)"},
                {0, 0, 0, 0}
            );
        END;
        IF esMod == 2 THEN
            avOK := INPUT(
                {avA, avB, avC, avD},
                "Risk Analysis (Cohort Study)",
                {"Exposed+/Case:", "Exposed+/No Case:", "Exposed-/Case:", "Exposed-/No Case:"},
                {"a: Exposed AND has outcome",
                 "b: Exposed AND no outcome",
                 "c: Not exposed AND has outcome",
                 "d: Not exposed AND no outcome"},
                {0, 0, 0, 0}
            );
        END;
        // v1.6: the stratified module's v1.4 forms, one stratum at a
        // time; the labels carry the stratum's subscript (a label
        // built in a variable is the device-verified form)
        IF esMod == 5 THEN
            avSb := CHAR(8320 + esStr);
            avS1 := "a" + avSb; avS2 := "b" + avSb; avS3 := "c" + avSb; avS4 := "d" + avSb;
            avOK := INPUT(
                {avA, avB, avC, avD},
                "Stratified 2×2: one stratum",
                {avS1, avS2, avS3, avS4},
                {"Exp+/Case (this stratum)", "Exp+/Control (this stratum)",
                 "Exp-/Case (this stratum)", "Exp-/Control (this stratum)"},
                {0, 0, 0, 0}
            );
        END;
        IF esMod == 6 THEN
            // the v1.4 cohort form asks for cases and TOTALS
            avA := esCel(1); avB := esCel(3);
            avC := esCel(1) + esCel(2); avD := esCel(3) + esCel(4);
            avSb := CHAR(8320 + esStr);
            avS1 := "Cases(exp)" + avSb; avS2 := "Cases(unexp)" + avSb;
            avS3 := "Total exp" + avSb; avS4 := "Total unexp" + avSb;
            avOK := INPUT(
                {avA, avB, avC, avD},
                "Stratified Cohort: one stratum",
                {avS1, avS2, avS3, avS4},
                {"Cases among exposed", "Cases among unexposed",
                 "Total exposed", "Total unexposed"},
                {0, 0, 0, 0}
            );
        END;
        IF esMod == 7 THEN
            avA := esCel(1); avB := esCel(3); avC := esCel(2); avD := esCel(4);
            avSb := CHAR(8320 + esStr);
            avS1 := "Cases(exp)" + avSb; avS2 := "Cases(unexp)" + avSb;
            avS3 := "PT(exp)" + avSb; avS4 := "PT(unexp)" + avSb;
            avOK := INPUT(
                {avA, avB, avC, avD},
                "Stratified Rates: one stratum",
                {avS1, avS2, avS3, avS4},
                {"Cases exposed", "Cases unexposed",
                 "Person-time exposed", "Person-time unexposed"},
                {0, 0, 0, 0}
            );
        END;
        DEFAULT
            avOK := INPUT(
                {avA, avB, avC, avD},
                "Diagnostic Test 2×2 Table",
                {"True Pos (a):", "False Pos (b):", "False Neg (c):", "True Neg (d):"},
                {"TP: Disease+ & Test+",
                 "FP: Disease- & Test+",
                 "FN: Disease+ & Test-",
                 "TN: Disease- & Test-"},
                {0, 0, 0, 0}
            );
    END;
    IF avOK == 0 THEN RETURN 0; END;
    IF avA < 0 OR avB < 0 OR avC < 0 OR avD < 0 THEN
        MSGBOX("Error: All values must be ≥ 0");
        RETURN 0;
    END;
    // the four grid cells, row by row, from what the form held
    avL := {avA, avB, avC, avD};
    IF esMod == 6 THEN
        IF avC < avA OR avD < avB THEN
            MSGBOX("A total cannot be smaller than its cases.");
            RETURN 0;
        END;
        avL := {avA, avC - avA, avB, avD - avB};
    END;
    IF esMod == 7 THEN avL := {avA, avC, avB, avD}; END;
    // counts are whole numbers: round, and say so if anything moved
    // (v1.6: person-time, cells b and d of the rates grid, can be any
    // number and is kept as typed)
    avR := 0;
    FOR avI FROM 1 TO 4 DO
        IF esMod == 7 AND (avI == 2 OR avI == 4) THEN
            esCel(avI) := avL(avI);
        ELSE
            IF FP(avL(avI)) <> 0 THEN avR := 1; END;
            esCel(avI) := ROUND(avL(avI), 0);
        END;
    END;
    IF avR == 1 THEN MSGBOX("Counts are whole numbers: the values were rounded."); END;
    RETURN 1;
END;

// Draw the grid (one frame on G1, one blit).  grTitle goes in the
// title band; grRows and grCols are the two row and the two column
// labels.  The selected cell (esSel) gets a red frame; the totals
// are worked out from esCel every frame, so they follow each edit.
es_GridDraw(grTitle, grRows, grCols)
BEGIN
    LOCAL grR, grC, grX, grY, grI, grS;
    LOCAL grXT, grYT, grFr, grFl, grN;
    es_DrawHeader(grTitle, "");            // a fresh G1 and the title band
    grXT := esGX0 + 2 * esGCW;             // totals column, left edge
    grYT := esGY0 + 2 * esGRH;             // totals row, top edge
    grN := esCel(1) + esCel(2) + esCel(3) + esCel(4);
    // ---- the column headers ----
    FOR grC FROM 1 TO 2 DO
        grX := esGX0 + (grC - 1) * esGCW;
        RECT_P(G1, grX, esGHY, grX + esGCW, esGHY + esGHH, esGRAY, esLGRY);
        TEXTOUT_P(grCols(grC), G1, grX + 8, esGHY + 5, 1, esBLUE, esGCW - 10);
    END;
    RECT_P(G1, grXT, esGHY, grXT + esGTW, esGHY + esGHH, esORNG, esLORN);
    // v1.6: the rates grid shows each row's rate (cases / PT) there
    IF esMod == 7 THEN grS := "Rate"; ELSE grS := "Total"; END;
    TEXTOUT_P(grS, G1, grXT + 8, esGHY + 5, 1, esORNG, esGTW - 10);
    // ---- the two data rows ----
    FOR grR FROM 1 TO 2 DO
        grY := esGY0 + (grR - 1) * esGRH;
        RECT_P(G1, esGLX, grY, esGX0 - 2, grY + esGRH, esGRAY, esLGRY);
        TEXTOUT_P(grRows(grR), G1, esGLX + 4, grY + 17, 1, esBLUE, esGX0 - esGLX - 7);
        FOR grC FROM 1 TO 2 DO
            grX := esGX0 + (grC - 1) * esGCW;
            grI := (grR - 1) * 2 + grC;
            // the selected cell: a red frame, two pixels thick, on pale red
            grFr := esGRAY; grFl := #FFFFFFh;
            IF grI == esSel THEN grFr := esRED; grFl := esLRED; END;
            RECT_P(G1, grX, grY, grX + esGCW, grY + esGRH, grFr, grFl);
            IF grI == esSel THEN
                RECT_P(G1, grX + 1, grY + 1, grX + esGCW - 1, grY + esGRH - 1, grFr, grFl);
            END;
            TEXTOUT_P(es_CellName(grI), G1, grX + 4, grY + 3, 1, esGRAY, esGCW - 6);
            // the count, big and centred (font 3 is about 9.5 px a digit)
            grS := es_Cnt(esCel(grI));      // v1.6: person-time may have decimals
            TEXTOUT_P(grS, G1, grX + 36 - IP(5 * DIM(grS)), grY + 17, 3, #000000h, esGCW - 4);
        END;
        // the row total
        RECT_P(G1, grXT, grY, grXT + esGTW, grY + esGRH, esORNG, esLORN);
        IF esMod == 7 THEN
            grS := es_RateTx(esCel(2 * grR - 1), esCel(2 * grR));
        ELSE
            grS := STRING(esCel(2 * grR - 1) + esCel(2 * grR), 2, 0);
        END;
        TEXTOUT_P(grS, G1, grXT + 8, grY + 16, 2, esORNG, esGTW - 10);
    END;
    // ---- the totals row and N ----
    RECT_P(G1, esGLX, grYT, esGX0 - 2, grYT + esGTH, esORNG, esLORN);
    TEXTOUT_P("Total", G1, esGLX + 4, grYT + 10, 1, esORNG, esGX0 - esGLX - 7);
    FOR grC FROM 1 TO 2 DO
        grX := esGX0 + (grC - 1) * esGCW;
        RECT_P(G1, grX, grYT, grX + esGCW, grYT + esGTH, esORNG, esLORN);
        TEXTOUT_P(es_Cnt(esCel(grC) + esCel(grC + 2)), G1, grX + 8, grYT + 9, 2, esORNG, esGCW - 10);
    END;
    RECT_P(G1, grXT, grYT, grXT + esGTW, grYT + esGTH, esORNG, esORNG);
    IF esMod == 7 THEN
        grS := es_RateTx(esCel(1) + esCel(3), esCel(2) + esCel(4));
    ELSE
        grS := "N = " + STRING(grN, 2, 0);
    END;
    TEXTOUT_P(grS, G1, grXT + 5, grYT + 9, 2, #FFFFFFh, esGTW - 7);
    // ---- notes under the table ----
    TEXTOUT_P("Tap a cell (or arrows + Enter) to type a count.", G1, 6, 180, 1, esGRAY, 308);
    CASE
        IF esMod == 1 THEN TEXTOUT_P("Rows = exposure, columns = outcome: a = both +.", G1, 6, 194, 1, esGRN, 308); END;
        IF esMod == 2 THEN TEXTOUT_P("Cohort: rows = exposure, columns = case or not.", G1, 6, 194, 1, esGRN, 308); END;
        IF esMod == 5 THEN TEXTOUT_P("Stratum " + STRING(esStr, 2, 0) + ": rows = exposure, cols = case/control.", G1, 6, 194, 1, esGRN, 308); END;
        IF esMod == 6 THEN TEXTOUT_P("Stratum " + STRING(esStr, 2, 0) + ": rows = exposure, cols = case or not.", G1, 6, 194, 1, esGRN, 308); END;
        IF esMod == 7 THEN TEXTOUT_P("Stratum " + STRING(esStr, 2, 0) + ": cases and person-time (PT) per row.", G1, 6, 194, 1, esGRN, 308); END;
        DEFAULT TEXTOUT_P("Columns = the truth (disease), rows = the test.", G1, 6, 194, 1, esGRN, 308);
    END;
    // no footer text: the soft-key strip (DRAWMENU) owns y 218-239
    BLIT_P(G0, 0, 0, G1);
END;

// The touch grid (port of ChiTable's csEntry).  Tap a cell, or move
// the red frame with the arrows and press Enter, to type that one
// cell's count.  The soft keys along the bottom:
//   Edit    type the selected cell's count       (Enter)
//   Values  all four counts in the module's form  (+); accepting the
//           form runs the analysis at once, as v1.4's form did
//   Clear   every cell back to 0
//   Cancel  back to the menu                      (Esc)
//   Run     check the table and go on to the results
// The labels never produce a key code (hp-prime-kit; ChiTable), so
// a tap in the strip, y >= 218, is read as slot MIN(6, IP(x/53.4)+1)
// and the keys stay as shortcuts.  Returns 1 if the table is ready,
// 0 if the user cancelled.  The counts are left in esCel.
es_Grid(gdTitle, gdRows, gdCols)
BEGIN
    LOCAL gdK, gdM, gdM1, gdGo, gdR, gdC;
    LOCAL gdV, gdOK, gdAct, gdLbl, gdHlp, gdRet;
    gdGo := 1; gdRet := 0;
    IF esSel < 1 OR esSel > 4 THEN esSel := 1; END;
    REPEAT gdK := GETKEY; UNTIL gdK == -1;      // drain the menu's key
    es_Drain();                                 // and its finger
    WHILE gdGo == 1 DO
        es_GridDraw(gdTitle, gdRows, gdCols);
        // the soft keys, painted on the SCREEN after the blit (DRAWMENU
        // draws on G0, not on G1), every frame
        DRAWMENU({"Edit", "Values", "Clear", "", "Cancel", "Run"});
        REPEAT
            gdK := GETKEY;
            gdM := MOUSE;
            gdM1 := gdM(1);
        UNTIL gdK > -1 OR SIZE(gdM1) > 0;
        // gdAct: 1 edit the selected cell, 2 the Values form, 3 clear,
        // 5 cancel, 6 run; 0 nothing
        gdAct := 0;
        gdR := IP((esSel - 1) / 2) + 1;         // the selected cell's row
        gdC := esSel - (gdR - 1) * 2;           // and column
        IF SIZE(gdM1) > 0 THEN
            es_Drain();                         // wait for the finger to lift
            IF gdM1(2) >= 218 THEN
                // the soft-key strip: six 53-px slots, left to right
                gdAct := MIN(6, IP(gdM1(1) / 53.4) + 1);
                IF gdAct == 4 THEN gdAct := 0; END;     // the blank key
            ELSE
                IF gdM1(1) >= esGX0 AND gdM1(2) >= esGY0 THEN
                    gdC := IP((gdM1(1) - esGX0) / esGCW) + 1;
                    gdR := IP((gdM1(2) - esGY0) / esGRH) + 1;
                    IF gdC >= 1 AND gdC <= 2 AND gdR >= 1 AND gdR <= 2 THEN
                        esSel := (gdR - 1) * 2 + gdC;
                        gdAct := 1;                     // a tap on a cell = Edit it
                    END;
                END;
            END;
        ELSE
            // the keys remain as shortcuts for the soft keys
            IF gdK == 7 THEN gdC := MAX(1, gdC - 1); END;
            IF gdK == 8 THEN gdC := MIN(2, gdC + 1); END;
            IF gdK == 2 THEN gdR := MAX(1, gdR - 1); END;
            IF gdK == 12 THEN gdR := MIN(2, gdR + 1); END;
            esSel := (gdR - 1) * 2 + gdC;
            IF gdK == 30 THEN gdAct := 1; END;        // Enter = Edit
            IF gdK == 50 THEN gdAct := 2; END;        // +     = Values
            IF gdK == 4 THEN gdAct := 5; END;         // Esc   = Cancel
        END;
        // ---- Edit: one cell, in a one-field form ----
        IF gdAct == 1 THEN
            gdR := IP((esSel - 1) / 2) + 1;
            gdC := esSel - (gdR - 1) * 2;
            gdV := esCel(esSel);
            gdLbl := es_CellName(esSel) + ":";
            gdHlp := gdRows(gdR) + " and " + gdCols(gdC) + ": a whole number, 0 or more";
            IF esMod == 7 AND gdC == 2 THEN gdHlp := gdRows(gdR) + " person-time: any number, 0 or more"; END;
            gdOK := INPUT({gdV}, "Count for one cell", {gdLbl}, {gdHlp});
            IF gdOK == 1 THEN
                IF gdV < 0 THEN
                    MSGBOX("A count cannot be negative.");
                ELSE
                    IF esMod == 7 AND gdC == 2 THEN
                        esCel(esSel) := gdV;          // v1.6: person-time, as typed
                    ELSE
                        IF FP(gdV) <> 0 THEN
                            MSGBOX("Counts are whole numbers: " + STRING(ROUND(gdV, 2), 2, 2) + " was rounded to the nearest one.");
                        END;
                        esCel(esSel) := ROUND(gdV, 0);
                    END;
                END;
            END;
        END;
        // ---- Values: the module's four-field form; OK runs at once ----
        IF gdAct == 2 THEN
            IF es_AskAll() == 1 THEN gdAct := 6; END;
        END;
        // ---- Clear: back to an empty table ----
        IF gdAct == 3 THEN
            esCel := {0, 0, 0, 0};
        END;
        // ---- Cancel: keep the counts, leave without computing ----
        IF gdAct == 5 THEN
            gdGo := 0; gdRet := 0;
        END;
        // ---- Run: an empty table cannot be analysed; say so and stay ----
        IF gdAct == 6 THEN
            IF esCel(1) + esCel(2) + esCel(3) + esCel(4) > 0 THEN
                gdGo := 0; gdRet := 1;
            ELSE
                MSGBOX("The table is empty: tap a cell and type its count first.");
            END;
        END;
        // the tap that closed a dialog can arrive again on the screen
        // underneath, so drain both before the next frame
        REPEAT gdK := GETKEY; UNTIL gdK == -1;
        es_Drain();
    END;
    RETURN gdRet;
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 1: 2×2 TABLE ANALYSIS
// ═══════════════════════════════════════════════════════════════════════════

// One line of the small font inside the worked-arithmetic box, in
// colour alCol; returns the y of the next line (13 px down).
es_ALine(alS, alY, alCol)
BEGIN
    TEXTOUT_P(alS, G1, 14, alY, 1, alCol, 294);
    RETURN alY + 13;
END;

// Two pieces on one line if they fit (about 44 small-font characters
// across the box), otherwise the second piece on its own line under
// the first, indented.  Big counts make the formulas long.
es_Line2(l2A, l2B, l2Y, l2Col)
BEGIN
    LOCAL l2S;
    IF DIM(l2A) + DIM(l2B) <= 44 THEN
        l2S := l2A + l2B;                 // both pieces are text already
        TEXTOUT_P(l2S, G1, 14, l2Y, 1, l2Col, 294);
        RETURN l2Y + 13;
    END;
    TEXTOUT_P(l2A, G1, 14, l2Y, 1, l2Col, 294);
    l2S := "   " + l2B;
    TEXTOUT_P(l2S, G1, 14, l2Y + 13, 1, l2Col, 294);
    RETURN l2Y + 26;
END;

// The worked arithmetic behind one row of page 3 (v1.5): apId 1 =
// odds ratio, 2 = risk ratio, 3 = risk difference, with the
// student's own cells put into the formula, then the CI built step
// by step.  Drawn as an orange box on top of the page already in G1,
// then one blit.  It uses 1.96, as es_CalcORCI, es_CalcRRCI and
// es_CalcRDCI do, so the box ends on the very CI the page shows.
es_Arith(apId, apA, apB, apC, apD)
BEGIN
    LOCAL apY, apS, apSE, apLo, apHi, apV;
    LOCAL apP1, apP2, apN1, apN2, apH, apZ;
    LOCAL apA2, apB2, apC2, apD2;
    // v1.7b: every decimal on this box is rounded before STRING (see
    // es_FmtPct): a risk of 1/2000 or an OR of 0.004 printed in
    // scientific form on the Prime's rule.
    apZ := 1.96;
    apN1 := apA + apB;                  // row totals: exposed, unexposed
    apN2 := apC + apD;
    // the box: a two-pixel orange frame on light orange
    RECT_P(G1, 6, 34, 313, 214, esORNG, esLORN);
    RECT_P(G1, 7, 35, 312, 213, esORNG, esLORN);
    apY := 40;
    CASE
        IF apId == 1 THEN
            // ---- the odds ratio: cross-product ratio, Woolf CI ----
            TEXTOUT_P("Odds ratio, step by step", G1, 14, apY, 2, esORNG, 292);
            apY := apY + 20;
            apS := "OR = (a×d)/(b×c) = (" + es_Cnt(apA) + "×" + es_Cnt(apD) + ")/(" + es_Cnt(apB) + "×" + es_Cnt(apC) + ")";
            IF apB * apC > 0 THEN
                apV := (apA * apD) / (apB * apC);
                apY := es_Line2(apS, " = " + STRING(ROUND(apV, 2), 2, 2), apY, #000000h);
            ELSE
                apY := es_ALine(apS, apY, #000000h);
                apY := es_ALine("b×c = 0, so the odds ratio is undefined.", apY, esRED);
            END;
            apY := apY + 4;
            apY := es_ALine("95% CI, worked on the log scale (Woolf):", apY, esBLUE);
            // a zero cell makes ln(OR) or the SE infinite: add 0.5 to
            // every cell first (Haldane-Anscombe), as es_CalcORCI does
            apH := 0;
            IF apA == 0 OR apB == 0 OR apC == 0 OR apD == 0 THEN apH := 0.5; END;
            IF apH > 0 THEN
                apY := es_ALine("A zero cell: 0.5 is added to every cell.", apY, esRED);
            END;
            apA2 := apA + apH; apB2 := apB + apH; apC2 := apC + apH; apD2 := apD + apH;
            apV := (apA2 * apD2) / (apB2 * apC2);
            apY := es_ALine("ln OR = ln(" + STRING(ROUND(apV, 3), 2, 3) + ") = " + STRING(ROUND(LN(apV), 3), 2, 3), apY, #000000h);
            apY := es_ALine("SE = sqrt(1/a + 1/b + 1/c + 1/d)", apY, #000000h);
            apSE := (1/apA2 + 1/apB2 + 1/apC2 + 1/apD2)^0.5;
            apY := es_Line2("   = sqrt(1/" + es_Cnt(apA2) + " + 1/" + es_Cnt(apB2) + " + 1/" + es_Cnt(apC2) + " + 1/" + es_Cnt(apD2) + ")", " = " + STRING(ROUND(apSE, 3), 2, 3), apY, #000000h);
            apLo := LN(apV) - apZ * apSE;
            apHi := LN(apV) + apZ * apSE;
            apY := es_ALine("ln OR - and + 1.96 × SE: " + STRING(ROUND(apLo, 3), 2, 3) + " to " + STRING(ROUND(apHi, 3), 2, 3), apY, #000000h);
            apY := es_ALine("CI = exp(" + STRING(ROUND(apLo, 3), 2, 3) + ") to exp(" + STRING(ROUND(apHi, 3), 2, 3) + ")", apY, #000000h);
            apY := es_ALine("   = " + STRING(ROUND(EXP(apLo), 2), 2, 2) + " to " + STRING(ROUND(EXP(apHi), 2), 2, 2), apY, esGRN);
        END;
        IF apId == 2 THEN
            // ---- the risk ratio: risk in the exposed over risk in the
            //      unexposed, CI on the log scale ----
            TEXTOUT_P("Risk ratio, step by step", G1, 14, apY, 2, esORNG, 292);
            apY := apY + 20;
            apY := es_ALine("RR = [a/(a+b)] / [c/(c+d)]", apY, #000000h);
            IF apN1 > 0 AND apN2 > 0 THEN
                apP1 := apA / apN1;
                apP2 := apC / apN2;
                apY := es_ALine("   = (" + es_Cnt(apA) + "/" + es_Cnt(apN1) + ") / (" + es_Cnt(apC) + "/" + es_Cnt(apN2) + ")", apY, #000000h);
                IF apP2 > 0 THEN
                    apV := apP1 / apP2;
                    apY := es_Line2("   = " + STRING(ROUND(apP1, 3), 2, 3) + " / " + STRING(ROUND(apP2, 3), 2, 3), " = " + STRING(ROUND(apV, 3), 2, 3), apY, #000000h);
                    apY := apY + 4;
                    IF apP1 > 0 THEN
                        apY := es_ALine("95% CI, worked on the log scale:", apY, esBLUE);
                        apY := es_ALine("ln RR = ln(" + STRING(ROUND(apV, 3), 2, 3) + ") = " + STRING(ROUND(LN(apV), 3), 2, 3), apY, #000000h);
                        apY := es_ALine("SE = sqrt((1-p1)/a + (1-p2)/c)", apY, #000000h);
                        apSE := ((1 - apP1)/apA + (1 - apP2)/apC)^0.5;
                        apY := es_Line2("   = sqrt(" + STRING(ROUND(1 - apP1, 3), 2, 3) + "/" + es_Cnt(apA) + " + " + STRING(ROUND(1 - apP2, 3), 2, 3) + "/" + es_Cnt(apC) + ")", " = " + STRING(ROUND(apSE, 3), 2, 3), apY, #000000h);
                        apLo := LN(apV) - apZ * apSE;
                        apHi := LN(apV) + apZ * apSE;
                        apY := es_ALine("ln RR - and + 1.96 × SE: " + STRING(ROUND(apLo, 3), 2, 3) + " to " + STRING(ROUND(apHi, 3), 2, 3), apY, #000000h);
                        apY := es_ALine("CI = exp(" + STRING(ROUND(apLo, 3), 2, 3) + ") to exp(" + STRING(ROUND(apHi, 3), 2, 3) + ")", apY, #000000h);
                        apY := es_ALine("   = " + STRING(ROUND(EXP(apLo), 2), 2, 2) + " to " + STRING(ROUND(EXP(apHi), 2), 2, 2), apY, esGRN);
                    ELSE
                        apY := es_ALine("a = 0, so RR = 0 and ln(0) does not exist:", apY, esRED);
                        apY := es_ALine("there is no log-scale CI.", apY, esRED);
                    END;
                ELSE
                    apY := es_ALine("c = 0: no events among the unexposed, so", apY, esRED);
                    apY := es_ALine("RR = p1 / 0 is undefined (and has no CI).", apY, esRED);
                END;
            ELSE
                apY := es_ALine("A row total is 0: a risk needs people in", apY, esRED);
                apY := es_ALine("both rows, so RR is undefined.", apY, esRED);
            END;
        END;
        DEFAULT
            // ---- the risk difference: Wald CI on the risk scale ----
            TEXTOUT_P("Risk difference, step by step", G1, 14, apY, 2, esORNG, 292);
            apY := apY + 20;
            apY := es_ALine("RD = a/(a+b) - c/(c+d)", apY, #000000h);
            IF apN1 > 0 AND apN2 > 0 THEN
                apP1 := apA / apN1;
                apP2 := apC / apN2;
                apV := apP1 - apP2;
                apY := es_Line2("   = " + es_Cnt(apA) + "/" + es_Cnt(apN1) + " - " + es_Cnt(apC) + "/" + es_Cnt(apN2), " = " + STRING(ROUND(apP1, 3), 2, 3) + " - " + STRING(ROUND(apP2, 3), 2, 3), apY, #000000h);
                apY := es_ALine("   = " + STRING(ROUND(apV, 3), 2, 3) + "   (" + STRING(ROUND(100 * apV, 1), 2, 1) + " %)", apY, #000000h);
                apY := apY + 4;
                apY := es_ALine("95% CI (Wald, on the risk scale):", apY, esBLUE);
                apY := es_ALine("SE = sqrt(p1(1-p1)/n1 + p2(1-p2)/n2)", apY, #000000h);
                apSE := (apP1 * (1 - apP1) / apN1 + apP2 * (1 - apP2) / apN2)^0.5;
                apY := es_Line2("   = sqrt(" + STRING(ROUND(apP1, 3), 2, 3) + "×" + STRING(ROUND(1 - apP1, 3), 2, 3) + "/" + es_Cnt(apN1), " + " + STRING(ROUND(apP2, 3), 2, 3) + "×" + STRING(ROUND(1 - apP2, 3), 2, 3) + "/" + es_Cnt(apN2) + ")", apY, #000000h);
                apY := es_ALine("   = " + STRING(ROUND(apSE, 3), 2, 3), apY, #000000h);
                apLo := apV - apZ * apSE;
                apHi := apV + apZ * apSE;
                apY := es_ALine("RD - and + 1.96 × SE: " + STRING(ROUND(apLo, 3), 2, 3) + " to " + STRING(ROUND(apHi, 3), 2, 3), apY, #000000h);
                // es_CalcRDCI keeps the interval inside -1 .. 1; say so
                // when that cut happens
                apS := "";
                IF apLo < -1 OR apHi > 1 THEN apS := " (cut at 100 %)"; END;
                IF apLo < -1 THEN apLo := -1; END;
                IF apHi > 1 THEN apHi := 1; END;
                apY := es_ALine("CI as %: " + STRING(ROUND(100 * apLo, 1), 2, 1) + " % to " + STRING(ROUND(100 * apHi, 1), 2, 1) + " %" + apS, apY, esGRN);
            ELSE
                apY := es_ALine("A row total is 0: a risk needs people in", apY, esRED);
                apY := es_ALine("both rows, so RD is undefined.", apY, esRED);
            END;
    END;
    TEXTOUT_P("any key or tap = close", G1, 14, 200, 1, esFOOT, 294);
    BLIT_P(G0, 0, 0, G1);
END;

// PAGE 4 of module 1 (v1.5): a mosaic of the 2×2, ported from
// ChiTable's csMosaic.  One bar per row (exposure group), its WIDTH
// the row's share of N; the two tiles in it are the outcome columns,
// their HEIGHTS the row percentages (Outcome+ on top).  If exposure
// and outcome were independent every bar would look like the grey
// "all" bar on the right.  Tile colour is the adjusted residual
// (Haberman): blue = more than expected, red = fewer, |adj| >= 2,
// darker beyond |4| (the shading Friendly and R's mosaicplot use).
// A tile is a hit box: a tap on it (or Up/Down) puts its O, E and
// row % in the panel underneath (esTile).
es_Mosaic(mcA, mcB, mcC, mcD)
BEGIN
    LOCAL moO, moRT, moCT, moN, moI, moR;
    LOCAL moC, moX, moY, moW, moH, moV;
    LOCAL moAW, moAH, moCol, moFill, moXR, moS;
    LOCAL moPL, moPR, moPT, moPB, moGap, moRN;
    LOCAL moCN;
    moO := {mcA, mcB, mcC, mcD};
    moRT := {mcA + mcB, mcC + mcD};      // row totals (exposed, unexposed)
    moCT := {mcA + mcC, mcB + mcD};      // column totals (outcome +, -)
    moN := mcA + mcB + mcC + mcD;
    moRN := {"Exposed+", "Exposed-"};
    moCN := {"Outcome+", "Outcome-"};
    // expected counts E = row total × column total / N, and the
    // adjusted residual (O - E) / sqrt(E (1 - R/N)(1 - C/N))
    FOR moI FROM 1 TO 4 DO
        moR := IP((moI - 1) / 2) + 1;
        moC := moI - (moR - 1) * 2;
        esE(moI) := moRT(moR) * moCT(moC) / moN;
        moV := esE(moI) * (1 - moRT(moR) / moN) * (1 - moCT(moC) / moN);
        esAdj(moI) := 0;                 // an empty row or column: no residual
        IF moV > 0 THEN esAdj(moI) := (moO(moI) - esE(moI)) / moV^0.5; END;
    END;
    // geometry: the bars from x 12 to 262, the "all" bar 274..306,
    // tiles from y 30 to 146, 4 px gaps
    moPL := 12; moPR := 262; moPT := 30; moPB := 146; moGap := 4;
    moAW := (moPR - moPL) - moGap;
    moAH := (moPB - moPT) - moGap;
    es_DrawHeader("MOSAIC PLOT", "6/7");
    moX := moPL;
    FOR moR FROM 1 TO 2 DO
        moW := IP(moAW * moRT(moR) / moN + 0.5);
        moY := moPT;
        FOR moC FROM 1 TO 2 DO
            moI := (moR - 1) * 2 + moC;
            moH := 0;
            IF moRT(moR) > 0 THEN moH := IP(moAH * moO(moI) / moRT(moR) + 0.5); END;
            // colour by the adjusted residual
            moCol := esGRAY; moFill := esLGRY;
            IF esAdj(moI) >= 2 THEN moCol := esBLUE; moFill := esLBLU; END;
            IF esAdj(moI) >= 4 THEN moFill := esMBLU; END;
            IF esAdj(moI) <= -2 THEN moCol := esRED; moFill := esLRED; END;
            IF esAdj(moI) <= -4 THEN moFill := esMRED; END;
            IF moI == esTile THEN moCol := esRED; END;
            IF moH > 0 AND moW > 0 THEN
                RECT_P(G1, moX, moY, moX + moW, moY + moH, moCol, moFill);
                IF moI == esTile THEN
                    RECT_P(G1, moX + 1, moY + 1, moX + moW - 1, moY + moH - 1, moCol, moFill);
                END;
                es_AddHit(moI, moX, moY, moX + moW, moY + moH);
                IF moW >= 40 AND moH >= 12 THEN
                    TEXTOUT_P(es_CellName(moI) + " = " + STRING(moO(moI), 2, 0), G1, moX + 3, moY + 2, 1, #000000h, moW - 4);
                END;
            END;
            moY := moY + moH + moGap;
        END;
        IF moW > 0 THEN
            // the row's name and size under its bar; just the name if
            // the bar is too narrow for both
            moS := moRN(moR) + " (" + STRING(moRT(moR), 2, 0) + ")";
            IF DIM(moS) * 7 > moW THEN moS := moRN(moR); END;
            TEXTOUT_P(moS, G1, moX + 2, moPB + 3, 1, #000000h, moW);
        END;
        moX := moX + moW + moGap;
    END;
    // ---- the reference bar: the column shares of all N ----
    moXR := 274;
    moY := moPT;
    FOR moC FROM 1 TO 2 DO
        moH := IP(moAH * moCT(moC) / moN + 0.5);
        IF moH > 0 THEN
            RECT_P(G1, moXR, moY, moXR + 32, moY + moH, esGRAY, esLGRY);
            IF moH >= 12 THEN TEXTOUT_P(STRING(100 * moCT(moC) / moN, 2, 0) + "%", G1, moXR + 3, moY + 2, 1, esGRAY, 28); END;
        END;
        moY := moY + moH + moGap;
    END;
    TEXTOUT_P("all", G1, moXR + 4, moPB + 3, 1, esGRAY, 30);
    TEXTOUT_P("top = Outcome+, height = row %, width = row N", G1, 6, 160, 1, esGRAY, 308);
    // ---- the panel: the tapped tile's numbers ----
    RECT_P(G1, 4, 174, 315, 215, esORNG, esLORN);
    IF esTile >= 1 AND esTile <= 4 THEN
        moR := IP((esTile - 1) / 2) + 1;
        moC := esTile - (moR - 1) * 2;
        moS := es_CellName(esTile) + ": " + moRN(moR) + ", " + moCN(moC) + ": O = " + STRING(moO(esTile), 2, 0) + ", E = " + STRING(ROUND(esE(esTile), 2), 2, 2);
        TEXTOUT_P(moS, G1, 10, 177, 1, #000000h, 302);
        IF moRT(moR) > 0 THEN
            moS := "row % = " + STRING(ROUND(100 * moO(esTile) / moRT(moR), 1), 2, 1) + " %   (all rows: " + STRING(ROUND(100 * moCT(moC) / moN, 1), 2, 1) + " %)";
        ELSE
            moS := "row % undefined: this row is empty";
        END;
        TEXTOUT_P(moS, G1, 10, 190, 1, esMAG, 302);
        moS := "adj. residual = " + es_Sgn(esAdj(esTile), 2);
        IF esAdj(esTile) >= 2 THEN moS := moS + ": MORE than expected"; END;
        IF esAdj(esTile) <= -2 THEN moS := moS + ": FEWER than expected"; END;
        IF ABS(esAdj(esTile)) < 2 THEN moS := moS + ": about as expected"; END;
        TEXTOUT_P(moS, G1, 10, 203, 1, esBLUE, 302);
    ELSE
        TEXTOUT_P("Tap a tile (or Up/Down): its O, E and row %.", G1, 10, 177, 1, #000000h, 302);
        TEXTOUT_P("blue = more than expected, red = fewer,", G1, 10, 190, 1, esGRAY, 302);
        TEXTOUT_P("when |adjusted residual| >= 2; grey = neither", G1, 10, 203, 1, esGRAY, 302);
    END;
    es_Foot("tap a tile = numbers, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
END;

// PAGE 1 of module 1 (v1.6): THE THREE READINGS.  The same four
// counts answer three different questions, depending on how the
// rows were sampled (ConTable2's idea):
//   Cohort      rows = exposure groups followed forward: risks are
//               real, so RR, OR, RD and the attributable fractions;
//   Diagnostic  rows = test result, columns = the truth: sens, spec,
//               PPV, NPV, the likelihood ratios, Youden, error rate;
//   Paired      rows = rater A, columns = rater B (or before/after on
//               the same people): kappa, McNemar, Yule's Q and Y.
// Three tabs across the top; a tap on a tab (or Up/Down) changes the
// reading (esRd).  The tabs are hit boxes 1, 2, 3.  The cohort ratios
// are the ones module 1 already stored in EP_RR, EP_OR, EP_RD.
es_ReadPage(rpA, rpB, rpC, rpD)
BEGIN
    LOCAL rpN, rpI, rpX, rpY, rpT, rpS;
    LOCAL rpRL, rpCL, rpSf, rpR1, rpR2, rpC1;
    LOCAL rpC2, rpV, rpW, rpSe, rpSp, rpPo;
    LOCAL rpPe, rpK, rpMc, rpQ, rpYu;
    rpN := rpA + rpB + rpC + rpD;
    rpR1 := rpA + rpB; rpR2 := rpC + rpD;
    rpC1 := rpA + rpC; rpC2 := rpB + rpD;
    es_DrawHeader("THREE READINGS", "1/7");
    // ---- the three tabs: the chosen one filled blue ----
    rpT := {"Cohort", "Diagnostic", "Paired"};
    FOR rpI FROM 1 TO 3 DO
        rpX := 2 + (rpI - 1) * 106;
        IF rpI == esRd THEN
            RECT_P(G1, rpX, 27, rpX + 104, 43, esBLUE, esBLUE);
            TEXTOUT_P(rpT(rpI), G1, rpX + 10, 30, 2, #FFFFFFh, 92);
        ELSE
            RECT_P(G1, rpX, 27, rpX + 104, 43, esGRAY, esLGRY);
            TEXTOUT_P(rpT(rpI), G1, rpX + 10, 30, 2, esBLUE, 92);
        END;
        es_AddHit(rpI, rpX, 25, rpX + 104, 45);
    END;
    // ---- the same four counts, labelled for this reading ----
    CASE
        IF esRd == 2 THEN
            rpRL := {"Test+", "Test-"}; rpCL := {"Disease+", "Disease-"};
            rpSf := {" TP", " FP", " FN", " TN"};
        END;
        IF esRd == 3 THEN
            rpRL := {"A: yes", "A: no"}; rpCL := {"B: yes", "B: no"};
            rpSf := {" agree", " differ", " differ", " agree"};
        END;
        DEFAULT
            rpRL := {"Exposed+", "Exposed-"}; rpCL := {"Outcome+", "Outcome-"};
            rpSf := {"", "", "", ""};
    END;
    RECT_P(G1, 4, 46, 315, 85, esGRID, esLBLU);
    TEXTOUT_P(rpCL(1), G1, 96, 48, 1, esBLUE, 90);
    TEXTOUT_P(rpCL(2), G1, 196, 48, 1, esBLUE, 84);
    TEXTOUT_P("total", G1, 284, 48, 1, esGRAY, 30);
    rpV := {rpA, rpB, rpC, rpD};
    rpS := {"a", "b", "c", "d"};
    FOR rpI FROM 1 TO 2 DO
        rpY := 48 + 12 * rpI;
        TEXTOUT_P(rpRL(rpI), G1, 10, rpY, 1, esBLUE, 82);
        TEXTOUT_P(rpS(2 * rpI - 1) + " = " + STRING(rpV(2 * rpI - 1), 2, 0) + rpSf(2 * rpI - 1), G1, 96, rpY, 1, #000000h, 96);
        TEXTOUT_P(rpS(2 * rpI) + " = " + STRING(rpV(2 * rpI), 2, 0) + rpSf(2 * rpI), G1, 196, rpY, 1, #000000h, 84);
        TEXTOUT_P(STRING(rpV(2 * rpI - 1) + rpV(2 * rpI), 2, 0), G1, 284, rpY, 1, esGRAY, 32);
    END;
    rpY := 90;
    // ---- COHORT: risks, ratios, attributable fractions ----
    IF esRd == 1 THEN
        IF rpR1 > 0 THEN rpS := es_FmtPct(rpA / rpR1); ELSE rpS := "undef (row empty)"; END;
        rpY := es_ALine("Risk if exposed, a/(a+b) = " + rpS, rpY, #000000h);
        IF rpR2 > 0 THEN rpS := es_FmtPct(rpC / rpR2); ELSE rpS := "undef (row empty)"; END;
        rpY := es_ALine("Risk if unexposed, c/(c+d) = " + rpS, rpY, #000000h);
        // v1.7c: es_EqTxt -- "RR = Re/Ru < 0.001", never "= < 0.001"
        rpY := es_ALine(es_EqTxt("RR = Re/Ru", es_FmtNum(EP_RR, 3)) + "   " + es_FmtCI(EP_RR_CI, 0), rpY, esGRN);
        rpY := es_ALine(es_EqTxt("OR = ad/bc", es_FmtNum(EP_OR, 3)) + "   " + es_FmtCI(EP_OR_CI, 0), rpY, esGRN);
        // v1.7: the conditional MLE beside it, in the exact colour
        // (orange); page 7 shows it and says why the two differ
        rpS := "  cond. MLE OR = " + es_CORTxt();
        IF DIM(rpS) <= 37 THEN rpS := rpS + ": page 7"; END;
        rpY := es_ALine(rpS, rpY, esORNG);
        rpY := es_ALine("RD = Re - Ru = " + es_FmtPct(EP_RD) + "   " + es_FmtCI(EP_RD_CI, 1), rpY, esGRN);
        // the attributable fractions, as module 2 defines them
        rpS := "undef";
        IF rpR1 > 0 AND rpR2 > 0 AND rpA > 0 THEN
            rpS := es_FmtPct((rpA / rpR1 - rpC / rpR2) / (rpA / rpR1));
        END;
        rpY := es_ALine("AF exposed = (Re - Ru)/Re = " + rpS, rpY, #000000h);
        rpS := "undef";
        IF rpR2 > 0 AND rpC1 > 0 THEN
            rpS := es_FmtPct((rpC1 / rpN - rpC / rpR2) / (rpC1 / rpN));
        END;
        rpY := es_ALine("AF population = (Rt - Ru)/Rt = " + rpS, rpY, #000000h);
        // v1.7: the two v1.6 notes share one line, to make room for
        // the conditional MLE above
        rpY := es_ALine("Rows = exposure: risks real; worked: page 5.", rpY, esGRAY);
    END;
    // ---- DIAGNOSTIC: columns are the truth ----
    IF esRd == 2 THEN
        rpSe := -1; rpSp := -1;
        rpS := "undef (no Disease+)";
        IF rpC1 > 0 THEN
            rpSe := rpA / rpC1;
            rpS := es_FmtPct(rpSe) + "  " + es_FmtCI(es_WilsonCI(rpA, rpC1), 1);
        END;
        rpY := es_ALine("Sensitivity a/(a+c) = " + rpS, rpY, esGRN);
        rpS := "undef (no Disease-)";
        IF rpC2 > 0 THEN
            rpSp := rpD / rpC2;
            rpS := es_FmtPct(rpSp) + "  " + es_FmtCI(es_WilsonCI(rpD, rpC2), 1);
        END;
        rpY := es_ALine("Specificity d/(b+d) = " + rpS, rpY, esGRN);
        // v1.7: the exact (Clopper-Pearson) CIs of the same two, in
        // orange, under Wilson's (esX1 is worked out once per table)
        IF esBinOK == 1 THEN
            rpS := "undef"; rpW := "undef";
            IF rpC1 > 0 THEN rpS := es_FmtCI({esX1(1), esX1(2)}, 1); END;
            IF rpC2 > 0 THEN rpW := es_FmtCI({esX1(3), esX1(4)}, 1); END;
            rpY := es_ALine("Exact: sens " + rpS + ", spec " + rpW, rpY, esORNG);
        ELSE
            rpY := es_ALine("Exact CIs unavailable (BINOMIAL check failed)", rpY, esGRAY);
        END;
        rpS := "undef"; rpW := "undef";
        IF rpR1 > 0 THEN rpS := es_FmtPct(rpA / rpR1); END;
        IF rpR2 > 0 THEN rpW := es_FmtPct(rpD / rpR2); END;
        rpY := es_ALine("PPV a/(a+b) = " + rpS + ",  NPV d/(c+d) = " + rpW, rpY, #000000h);
        rpS := "undef"; rpW := "undef";
        IF rpSe >= 0 AND rpSp >= 0 THEN
            IF rpSp < 1 THEN rpS := es_FmtNum(rpSe / (1 - rpSp), 2); END;
            IF rpSp > 0 THEN rpW := es_FmtNum((1 - rpSe) / rpSp, 3); END;
        END;
        // v1.7c: es_EqTxt on both ratios ("LR- < 0.001" for a near-perfect test)
        rpY := es_ALine(es_EqTxt("LR+ = sens/(1-spec)", rpS) + es_EqTxt(",  LR-", rpW), rpY, #000000h);
        rpS := "undef";
        IF rpSe >= 0 AND rpSp >= 0 THEN rpS := STRING(ROUND(rpSe + rpSp - 1, 3), 2, 3); END;
        rpY := es_ALine("Youden J = sens + spec - 1 = " + rpS, rpY, #000000h);
        rpY := es_ALine("Accuracy = " + es_FmtPct((rpA + rpD) / rpN) + ", error rate (b+c)/N = " + es_FmtPct((rpB + rpC) / rpN), rpY, #000000h);
        rpY := es_ALine("Prevalence (a+c)/N = " + es_FmtPct(rpC1 / rpN) + ": PPV, NPV hold here", rpY, #000000h);
        rpY := es_ALine("Columns = the truth; module 3 has more pages.", rpY, esGRAY);
    END;
    // ---- PAIRED: agreement, and change between the two ratings ----
    IF esRd == 3 THEN
        rpPo := (rpA + rpD) / rpN;
        rpPe := (rpR1 / rpN) * (rpC1 / rpN) + (rpR2 / rpN) * (rpC2 / rpN);
        rpK := 1;
        IF rpPe < 1 THEN rpK := (rpPo - rpPe) / (1 - rpPe); END;
        rpY := es_ALine("Agreement po = (a+d)/N = " + es_FmtPct(rpPo), rpY, #000000h);
        rpY := es_ALine("Agreement by chance pe = " + es_FmtPct(rpPe), rpY, #000000h);
        rpY := es_ALine("Kappa = (po-pe)/(1-pe) = " + STRING(ROUND(rpK, 3), 2, 3) + " (" + es_KapTxt(rpK) + ")", rpY, esGRN);
        rpMc := es_McNemar(rpB, rpC);
        IF rpMc(1) >= 0 THEN
            rpY := es_ALine("McNemar χ² (cont. corr.) = " + STRING(ROUND(rpMc(1), 3), 2, 3) + ", " + es_PTxt(rpMc(2), 4), rpY, esGRN);
        ELSE
            rpY := es_ALine("McNemar: undefined (b + c = 0)", rpY, esGRAY);
        END;
        rpY := es_ALine("  only the pairs that differ: b = " + STRING(rpB, 2, 0) + ", c = " + STRING(rpC, 2, 0), rpY, esGRAY);
        rpS := "undef";
        IF rpA * rpD + rpB * rpC > 0 THEN rpS := STRING(ROUND((rpA * rpD - rpB * rpC) / (rpA * rpD + rpB * rpC), 3), 2, 3); END;
        rpY := es_ALine("Yule's Q = (ad-bc)/(ad+bc) = " + rpS, rpY, #000000h);
        rpQ := (rpA * rpD)^0.5; rpYu := (rpB * rpC)^0.5;
        rpS := "undef";
        IF rpQ + rpYu > 0 THEN rpS := STRING(ROUND((rpQ - rpYu) / (rpQ + rpYu), 3), 2, 3); END;
        rpY := es_ALine("Yule's Y (on sqrt(ad), sqrt(bc)) = " + rpS, rpY, #000000h);
        rpY := es_ALine("Disagreement (error) rate (b+c)/N = " + es_FmtPct((rpB + rpC) / rpN), rpY, #000000h);
        rpY := es_ALine("Same pairs rated twice: McNemar, not χ².", rpY, esGRAY);
    END;
    es_Foot("tap a tab = reading, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
END;

// v1.6: the hypergeometric distribution behind Fisher's exact test,
// worked out once per visit to page 4 and kept in esQP / esQK.  With
// the row and column totals fixed, the table is decided by a alone,
// and P(a') = C(a+c, a') C(b+d, a+b-a') / C(N, a+b), computed in log
// space with es_lngamma exactly as es_FisherExact does, so the
// magenta bars add up to the very p on page 3.  Bars with P below
// 1E-7 of the tallest are too small to draw; they are left out (and
// counted in esQCut), but the observed table is always kept.
//   esQP  P(a') per bar     esQK  1 = counted in the two-sided p
//   esQLo the a' of bar 1   esQN  number of bars
//   esQP0 P(observed)       esQPv the two-sided p
//   esQOK 0 not worked out, 1 ready, 2 N > 2000 (not computed)
es_HyperFill(hfA, hfB, hfC, hfD)
BEGIN
    LOCAL hfR1, hfR2, hfC1, hfC2, hfN, hfI;
    LOCAL hfMin, hfMax, hfCst, hfL0, hfMx, hfS;
    LOCAL hfLo, hfHi, hfLL, hfNA, hfOb, hfAp;
    hfR1 := hfA + hfB; hfR2 := hfC + hfD;
    hfC1 := hfA + hfC; hfC2 := hfB + hfD;
    hfN := hfR1 + hfR2;
    esQOK := 2;
    IF hfN > 2000 THEN RETURN 0; END;
    hfCst := es_lngamma(hfR1+1) + es_lngamma(hfR2+1) + es_lngamma(hfC1+1) + es_lngamma(hfC2+1) - es_lngamma(hfN+1);
    hfL0 := hfCst - es_lngamma(hfA+1) - es_lngamma(hfB+1) - es_lngamma(hfC+1) - es_lngamma(hfD+1);
    hfMin := MAX(0, hfR1 - hfC2);
    hfMax := MIN(hfR1, hfC1);
    hfNA := hfMax - hfMin + 1;
    // every a' from hfMin to hfMax: its log probability, the largest,
    // and the sum of those no more likely than the observed table
    hfLL := MAKELIST(0, K, 1, hfNA, 1);
    hfMx := hfL0; hfS := 0;
    FOR hfI FROM 1 TO hfNA DO
        hfAp := hfMin + hfI - 1;
        hfLL(hfI) := hfCst - es_lngamma(hfAp+1) - es_lngamma(hfR1-hfAp+1) - es_lngamma(hfC1-hfAp+1) - es_lngamma(hfC2-hfR1+hfAp+1);
        IF hfLL(hfI) > hfMx THEN hfMx := hfLL(hfI); END;
        IF hfLL(hfI) <= hfL0 + 0.00000001 THEN hfS := hfS + EXP(hfLL(hfI)); END;
    END;
    // trim the far tails (ln 1E-7 = -16.1), never past the observed bar
    hfOb := hfA - hfMin + 1;
    hfLo := 1;
    WHILE hfLo < hfOb AND hfLL(hfLo) < hfMx - 16.1 DO hfLo := hfLo + 1; END;
    hfHi := hfNA;
    WHILE hfHi > hfOb AND hfLL(hfHi) < hfMx - 16.1 DO hfHi := hfHi - 1; END;
    esQN := hfHi - hfLo + 1;
    esQLo := hfMin + hfLo - 1;
    esQCut := hfNA - esQN;
    esQP := MAKELIST(0, K, 1, esQN, 1);
    esQK := MAKELIST(0, K, 1, esQN, 1);
    FOR hfI FROM 1 TO esQN DO
        esQP(hfI) := EXP(hfLL(hfLo + hfI - 1));
        IF hfLL(hfLo + hfI - 1) <= hfL0 + 0.00000001 THEN esQK(hfI) := 1; END;
    END;
    esQP0 := EXP(hfL0);
    esQPv := MIN(1, hfS);
    esQSel := hfA - esQLo + 1;           // the panel opens on your own table
    esQOK := 1;
    RETURN 1;
END;

// PAGE 4 of module 1 (v1.6): FISHER'S TEST AS A COUNTING ARGUMENT.
// One bar for every table with your row and column totals, its
// height P(a'): your table in red, every table no more likely than
// yours in magenta (their sum IS the two-sided p), the more likely
// ones in pale grey.  A thin strip under the axis repeats the colours
// so that bars too short to see still show whether they count.  A
// tap on the plot (hit box 20), or Up/Down, picks a bar; the orange
// corner panel shows that table, its P and whether it counts.  The
// panel sits in the corner away from the peak.  Plot box: x from esPL
// (34) to esPR (306), the same constants the tap handler uses; y
// from 46 down to the axis at 150.
es_HyperPage(hgA, hgB, hgC, hgD)
BEGIN
    LOCAL hgI, hgX1, hgX2, hgH, hgCol, hgMx;
    LOCAL hgTop, hgStp, hgDec, hgV, hgY, hgPt;
    LOCAL hgOb, hgMd, hgPx, hgS, hgAp, hgTk;
    LOCAL hgXc, hgLb;
    IF esQOK == 0 THEN es_HyperFill(hgA, hgB, hgC, hgD); END;
    es_DrawHeader("FISHER: EVERY TABLE", "4/7");
    IF esQOK <> 1 THEN
        TEXTOUT_P("N > 2000: Fisher's exact test is not", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("computed here (too many tables to add);", G1, 12, 58, 1, #000000h, 296);
        TEXTOUT_P("use the chi-square on page 3.", G1, 12, 72, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN 0;
    END;
    // ---- the y scale: the tallest bar at about 93 % of the box ----
    hgMx := 0; hgMd := 1;
    FOR hgI FROM 1 TO esQN DO
        IF esQP(hgI) > hgMx THEN hgMx := esQP(hgI); hgMd := hgI; END;
    END;
    hgTop := hgMx * 1.08;
    hgStp := es_Nice(hgMx / 4);
    hgDec := MAX(0, -FLOOR(LOG(hgStp)));
    hgV := 0;
    WHILE hgV <= hgTop DO
        hgY := 150 - IP(hgV / hgTop * 104 + 0.5);
        LINE_P(G1, esPL, hgY, esPR, hgY, esGRID);
        TEXTOUT_P(STRING(hgV, 2, hgDec), G1, 0, hgY - 5, 1, esGRAY, 33);
        hgV := hgV + hgStp;
    END;
    // ---- the bars ----
    hgPt := (esPR - esPL) / esQN;            // pixels per bar (can be < 1)
    hgOb := hgA - esQLo + 1;                 // your table's bar
    FOR hgI FROM 1 TO esQN DO
        hgX1 := esPL + IP((hgI - 1) * hgPt);
        hgX2 := esPL + IP(hgI * hgPt) - 1;
        IF hgPt >= 4 THEN hgX1 := hgX1 + 1; END;      // a gap between wide bars
        IF hgX2 < hgX1 THEN hgX2 := hgX1; END;
        hgH := IP(esQP(hgI) / hgTop * 104 + 0.5);
        hgCol := #C8C8C8h;                             // more likely than yours
        IF esQK(hgI) == 1 THEN hgCol := esMAG; END;    // counted in the p
        IF hgI == hgOb THEN hgCol := esRED; END;       // your table
        IF hgH > 0 THEN RECT_P(G1, hgX1, 150 - hgH, hgX2, 150, hgCol, hgCol); END;
        RECT_P(G1, hgX1, 152, hgX2, 154, hgCol, hgCol);
        IF hgI == esQSel THEN
            // a small black arrow over the picked bar
            hgXc := IP((hgX1 + hgX2) / 2);
            LINE_P(G1, hgXc, 141 - hgH, hgXc, 147 - hgH, #000000h);
            LINE_P(G1, hgXc - 2, 145 - hgH, hgXc, 147 - hgH, #000000h);
            LINE_P(G1, hgXc + 2, 145 - hgH, hgXc, 147 - hgH, #000000h);
        END;
    END;
    LINE_P(G1, esPL, 46, esPL, 150, #000000h);
    LINE_P(G1, esPL, 150, esPR, 150, #000000h);
    // ---- x ticks: values of a' ----
    hgTk := MAX(1, es_Nice(esQN / 6));         // whole values of a' only
    hgLb := CEILING(esQLo / hgTk) * hgTk;
    WHILE hgLb <= esQLo + esQN - 1 DO
        hgXc := esPL + IP((hgLb - esQLo + 0.5) * hgPt);
        LINE_P(G1, hgXc, 155, hgXc, 158, #000000h);
        TEXTOUT_P(STRING(hgLb, 2, 0), G1, hgXc - 6, 158, 1, esGRAY, 30);
        hgLb := hgLb + hgTk;
    END;
    TEXTOUT_P("a =", G1, 4, 158, 1, esGRAY, 26);
    // ---- the legend ----
    TEXTOUT_P("red = your table (a = " + STRING(hgA, 2, 0) + "), " + es_EqTxt("P", es_FmtNum(esQP0, 6)), G1, 6, 170, 1, esRED, 308);
    TEXTOUT_P("magenta = P <= yours; they sum to " + es_EqTxt("p", es_FmtNum(esQPv, 6)), G1, 6, 182, 1, esMAG, 308);
    TEXTOUT_P("grey = tables more likely than yours", G1, 6, 194, 1, esGRAY, 308);
    IF esQCut > 0 THEN
        hgS := STRING(esQCut, 2, 0) + " more tables have P near 0 (not drawn)";
    ELSE
        hgS := "all " + STRING(esQN, 2, 0) + " tables with these row/column totals";
    END;
    IF esQN > 60 THEN hgS := STRING(esQN, 2, 0) + " bars: too narrow to label; a tap picks one"; END;
    TEXTOUT_P(hgS, G1, 6, 206, 1, esGRAY, 308);
    // ---- the corner panel: the picked bar's table ----
    IF esQSel >= 1 AND esQSel <= esQN THEN
        hgAp := esQLo + esQSel - 1;
        hgPx := esPR - 112;
        IF esPL + (hgMd - 0.5) * hgPt > (esPL + esPR) / 2 THEN hgPx := esPL + 4; END;
        RECT_P(G1, hgPx, 48, hgPx + 108, 116, esORNG, esLORN);
        hgS := "a = " + STRING(hgAp, 2, 0);
        IF esQSel == hgOb THEN hgS := hgS + " (yours)"; END;
        TEXTOUT_P(hgS, G1, hgPx + 5, 51, 1, IFTE(esQSel == hgOb, esRED, #000000h), 100);
        TEXTOUT_P(STRING(hgAp, 2, 0) + "   " + STRING(hgA + hgB - hgAp, 2, 0) + "  | " + STRING(hgA + hgB, 2, 0), G1, hgPx + 5, 64, 1, #000000h, 100);
        TEXTOUT_P(STRING(hgA + hgC - hgAp, 2, 0) + "   " + STRING(hgD - hgA + hgAp, 2, 0) + "  | " + STRING(hgC + hgD, 2, 0), G1, hgPx + 5, 76, 1, #000000h, 100);
        TEXTOUT_P(es_EqTxt("P", es_FmtNum(esQP(esQSel), 6)), G1, hgPx + 5, 89, 1, #000000h, 100);
        IF esQK(esQSel) == 1 THEN
            TEXTOUT_P("counted in p", G1, hgPx + 5, 102, 1, esMAG, 100);
        ELSE
            TEXTOUT_P("not counted", G1, hgPx + 5, 102, 1, esGRAY, 100);
        END;
    END;
    es_AddHit(20, esPL, 44, esPR, 162);
    es_Foot("tap a bar = its table, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
    RETURN 1;
END;

// v1.7: the picture on page 7, worked out on the first visit for each
// table from esNL (es_CondOR filled it).  For every possible a':
//   esOP  P(a') at the conditional MLE (all 0 when the MLE is 0 or
//         infinite: then all the probability sits on your a)
//   esOQ  P(a') when OR = 1, the Fisher bars of page 4
// and only the stretch of a' where either is at least 1/10000 of the
// tallest bar is drawn (your a always included):
//   esOLo the first index drawn, esON how many, esOMx the tallest,
//   esOE0 the mean of a' when OR = 1 (= (a+b)(a+c)/N)
//   esOOK 0 not worked out, 1 ready, 2 nothing to draw (state 3 or 4)
es_CORFill(cfA)
BEGIN
  LOCAL cfS, cfI, cfT, cfG, cfM1, cfM0;
  LOCAL cfZ1, cfZ0, cfLo, cfHi, cfMx, cfOb;
  esOOK := 2;
  IF esCO(4) >= 3 THEN RETURN 0; END;
  cfS := esNHi - esNLo + 1;
  cfT := 0;
  IF esCO(4) == 0 THEN cfT := LN(esCO(1)); END;
  // the largest log weight of each distribution, so EXP never overflows
  cfM1 := esNL(1) + esNLo * cfT; cfM0 := esNL(1);
  FOR cfI FROM 2 TO cfS DO
    cfG := esNL(cfI) + (esNLo + cfI - 1) * cfT;
    IF cfG > cfM1 THEN cfM1 := cfG; END;
    IF esNL(cfI) > cfM0 THEN cfM0 := esNL(cfI); END;
  END;
  esOP := MAKELIST(0, K, 1, cfS, 1);
  esOQ := MAKELIST(0, K, 1, cfS, 1);
  cfZ1 := 0; cfZ0 := 0;
  FOR cfI FROM 1 TO cfS DO
    IF esCO(4) == 0 THEN
      cfG := esNL(cfI) + (esNLo + cfI - 1) * cfT - cfM1;
      IF cfG > -46 THEN esOP(cfI) := EXP(cfG); END;
    END;
    cfG := esNL(cfI) - cfM0;
    IF cfG > -46 THEN esOQ(cfI) := EXP(cfG); END;
    cfZ1 := cfZ1 + esOP(cfI); cfZ0 := cfZ0 + esOQ(cfI);
  END;
  esOE0 := 0; cfMx := 0;
  FOR cfI FROM 1 TO cfS DO
    IF cfZ1 > 0 THEN esOP(cfI) := esOP(cfI) / cfZ1; END;
    esOQ(cfI) := esOQ(cfI) / cfZ0;
    esOE0 := esOE0 + (esNLo + cfI - 1) * esOQ(cfI);
    IF esOP(cfI) > cfMx THEN cfMx := esOP(cfI); END;
    IF esOQ(cfI) > cfMx THEN cfMx := esOQ(cfI); END;
  END;
  // trim the far tails of both, never past your a
  cfOb := cfA - esNLo + 1;
  cfLo := 1;
  WHILE cfLo < cfOb AND MAX(esOP(cfLo), esOQ(cfLo)) < cfMx / 10000 DO cfLo := cfLo + 1; END;
  cfHi := cfS;
  WHILE cfHi > cfOb AND MAX(esOP(cfHi), esOQ(cfHi)) < cfMx / 10000 DO cfHi := cfHi - 1; END;
  esOLo := cfLo; esON := cfHi - cfLo + 1; esOMx := cfMx;
  esOOK := 1;
  RETURN 1;
END;

// PAGE 7 of module 1 (v1.7): THE EXACT ODDS RATIO.  The sample OR
// ad/bc (with its Woolf CI) beside the conditional MLE (with its exact
// CI), and a picture of what "conditional MLE" means: the orange bars
// are the distribution of a' when the true OR is the MLE -- their mean
// is exactly your a (the red line); the grey line is the same
// distribution when OR = 1 (page 4's Fisher bars).  The plot box is
// x esPL..esPR (34..306), y 62 to the axis at 148.
es_CORPage(cpA, cpB, cpC, cpD)
BEGIN
  LOCAL cpI, cpX1, cpX2, cpH, cpY, cpTop;
  LOCAL cpPt, cpS, cpX, cpTk, cpLb, cpOb;
  LOCAL cpPx, cpPy, cpLx, cpLy, cpXc;
  IF esOOK == 0 THEN es_CORFill(cpA); END;
  es_DrawHeader("EXACT ODDS RATIO", "7/7");
  cpS := "undefined";
  IF EP_OR <> 999 THEN cpS := es_SigTxt(EP_OR); END;
  // v1.7c: es_EqTxt -- "Sample OR = ad/bc < 0.000001", never "= <"
  TEXTOUT_P(es_EqTxt("Sample OR = ad/bc", cpS) + ", Woolf CI " + es_FmtCI(EP_OR_CI, 0), G1, 6, 27, 1, #000000h, 308);
  TEXTOUT_P("Conditional MLE OR = " + es_CORTxt(), G1, 6, 39, 1, esORNG, 308);
  TEXTOUT_P("(exact CI; R's fisher.test and SciPy report it)", G1, 6, 51, 1, esGRAY, 308);
  IF esOOK <> 1 THEN
    IF esCO(4) == 4 THEN
      TEXTOUT_P("N > 2000: not computed here, the same cap", G1, 12, 70, 1, #000000h, 296);
      TEXTOUT_P("as Fisher's test on page 3 (too many tables).", G1, 12, 84, 1, #000000h, 296);
    ELSE
      TEXTOUT_P("A row or column total is 0: with every table", G1, 12, 70, 1, #000000h, 296);
      TEXTOUT_P("the same, no odds ratio can be estimated.", G1, 12, 84, 1, #000000h, 296);
    END;
    es_DrawFooter(1);
    RETURN 0;
  END;
  // ---- the y scale, and a light grid at a quarter and a half ----
  cpTop := esOMx * 1.08;
  FOR cpI FROM 1 TO 3 DO
    cpY := 148 - IP(cpI * 21 + 0.5);
    LINE_P(G1, esPL, cpY, esPR, cpY, esGRID);
  END;
  // ---- the orange bars: P(a') at the MLE ----
  cpPt := (esPR - esPL) / esON;            // pixels per bar (can be < 1)
  cpOb := cpA - esNLo - esOLo + 2;         // your a, as a drawn bar (1..esON)
  FOR cpI FROM 1 TO esON DO
    cpX1 := esPL + IP((cpI - 1) * cpPt);
    cpX2 := esPL + IP(cpI * cpPt) - 1;
    IF cpPt >= 4 THEN cpX1 := cpX1 + 1; END;
    IF cpX2 < cpX1 THEN cpX2 := cpX1; END;
    cpH := IP(esOP(esOLo + cpI - 1) / cpTop * 84 + 0.5);
    IF cpH > 0 THEN RECT_P(G1, cpX1, 148 - cpH, cpX2, 148, esORNG, #FFA060h); END;
  END;
  // ---- the grey line: P(a') when OR = 1, through the bar centres ----
  cpLx := -1; cpLy := -1;
  FOR cpI FROM 1 TO esON DO
    cpPx := esPL + IP((cpI - 0.5) * cpPt);
    cpPy := 148 - IP(esOQ(esOLo + cpI - 1) / cpTop * 84 + 0.5);
    IF cpLx >= 0 THEN LINE_P(G1, cpLx, cpLy, cpPx, cpPy, esGRAY); END;
    IF cpPt >= 6 THEN RECT_P(G1, cpPx - 1, cpPy - 1, cpPx + 1, cpPy + 1, esGRAY, esGRAY); END;
    cpLx := cpPx; cpLy := cpPy;
  END;
  // ---- the mean when OR = 1, dotted grey; your a, red ----
  cpXc := esPL + IP((esOE0 - esNLo - esOLo + 1.5) * cpPt);
  IF cpXc >= esPL AND cpXc <= esPR THEN
    FOR cpY FROM 62 TO 146 STEP 3 DO PIXON_P(G1, cpXc, cpY, esGRAY); END;
  END;
  cpXc := esPL + IP((cpOb - 0.5) * cpPt);
  LINE_P(G1, cpXc, 60, cpXc, 148, esRED);
  cpS := "a = " + STRING(cpA, 2, 0);
  IF cpXc > esPR - 50 THEN
    TEXTOUT_P(cpS, G1, cpXc - 48, 60, 1, esRED, 46);
  ELSE
    TEXTOUT_P(cpS, G1, cpXc + 3, 60, 1, esRED, 46);
  END;
  LINE_P(G1, esPL, 60, esPL, 148, #000000h);
  LINE_P(G1, esPL, 148, esPR, 148, #000000h);
  // ---- x ticks: values of a' (the Fisher page's scheme) ----
  cpTk := MAX(1, es_Nice(esON / 6));
  cpLb := CEILING((esNLo + esOLo - 1) / cpTk) * cpTk;
  WHILE cpLb <= esNLo + esOLo + esON - 2 DO
    cpX := esPL + IP((cpLb - esNLo - esOLo + 1.5) * cpPt);
    LINE_P(G1, cpX, 149, cpX, 152, #000000h);
    TEXTOUT_P(STRING(cpLb, 2, 0), G1, cpX - 6, 152, 1, esGRAY, 30);
    cpLb := cpLb + cpTk;
  END;
  TEXTOUT_P("a =", G1, 4, 152, 1, esGRAY, 26);
  // ---- the reading ----
  IF esCO(4) == 0 THEN
    TEXTOUT_P("orange = P(a) at the MLE: its mean is your a", G1, 6, 164, 1, esORNG, 308);
  ELSE
    IF esCO(4) == 1 THEN cpS := "MLE 0: your a is the smallest it can be"; ELSE cpS := "MLE infinite: your a is the largest it can be"; END;
    TEXTOUT_P(cpS, G1, 6, 164, 1, esORNG, 308);
  END;
  TEXTOUT_P("grey = P(a) if OR = 1 (page 4); its mean is " + STRING(ROUND(esOE0, 2), 2, 2), G1, 6, 176, 1, esGRAY, 308);
  TEXTOUT_P("Why two? This one fixes all four totals, as", G1, 6, 188, 1, #000000h, 308);
  TEXTOUT_P("Fisher's test does: nearer 1; big N: the same.", G1, 6, 200, 1, #000000h, 308);
  es_DrawFooter(1);
  RETURN 1;
END;

es_Module_Table2x2()
BEGIN
    LOCAL a, b, c, d;
    LOCAL r1, r2, c1, c2, N, p1;
    LOCAL ea, eb, ec, ed;
    LOCAL chisq, chisq_yates, chisq_p, chisq_yates_p;
    LOCAL fisher_p, phi, pearsonR;
    LOCAL oddsR, RR, RD;
    LOCAL page, action;
    LOCAL inputOK, fpt, mcn, xcp;

    // v1.5: the touch grid replaces v1.4's INPUT form (that form is
    // still one tap away: the Values soft key, or +).  The grid opens
    // on this module's last table.
    esMod := 1;
    esCel := esM1;
    IF esDir == 1 THEN
        inputOK := 1;          // v1.6: EpiStats2x2(a,b,c,d) from Home: no grid
    ELSE
        inputOK := es_Grid("2×2 TABLE: COUNTS", {"Exposed+", "Exposed-"}, {"Outcome+", "Outcome-"});
    END;
    esM1 := esCel;
    IF inputOK == 0 THEN RETURN; END;
    a := esCel(1); b := esCel(2); c := esCel(3); d := esCel(4);

    // the grid never lets a negative or an empty table through; the
    // v1.4 checks stay as a guard
    IF a < 0 OR b < 0 OR c < 0 OR d < 0 THEN
        MSGBOX("Error: All values must be ≥ 0");
        RETURN;
    END;

    N := a + b + c + d;
    IF N == 0 THEN
        MSGBOX("Error: Table cannot be empty");
        RETURN;
    END;

    EP_table := {a, b, c, d};
    EP_N := N;

    r1 := a + b;
    r2 := c + d;
    c1 := a + c;
    c2 := b + d;
    p1 := r1 * r2 * c1 * c2;

    ea := r1 * c1 / N;
    eb := r1 * c2 / N;
    ec := r2 * c1 / N;
    ed := r2 * c2 / N;

    IF ea > 0 AND eb > 0 AND ec > 0 AND ed > 0 THEN
        chisq := (a-ea)^2/ea + (b-eb)^2/eb + (c-ec)^2/ec + (d-ed)^2/ed;
        // v1.7c: the upper tail with 1 df, 2 NORMALD_CDF(-sqrt(x)) -- the same number as
        // 1 - CHISQUARE_CDF, which is exactly 0 for a chi-square above about 52
        chisq_p := 2 * NORMALD_CDF(0, 1, -(chisq^0.5));
    ELSE
        chisq := 0;
        chisq_p := 1;
    END;
    EP_chisq := chisq;
    EP_chisq_p := chisq_p;

    IF p1 > 0 THEN
        // MAX(0,...) is REQUIRED. When |ad-bc| < N/2 the parenthesis goes negative
    // and squaring flips it positive, so the "conservative" Yates statistic
    // comes out LARGER than uncorrected Pearson. On the null table (5,5,5,5)
    // the unclamped form gives X2 = 0.2, p = 0.655 where Pearson gives 0 and
    // p = 1.000. Clamping matches scipy's chi2_contingency(correction=True).
    chisq_yates := N * MAX(0, ABS(a*d - b*c) - N/2)^2 / p1;
        // v1.7c: the upper tail with 1 df, as for Pearson's (never exactly 0)
        chisq_yates_p := 2 * NORMALD_CDF(0, 1, -(chisq_yates^0.5));
    ELSE
        chisq_yates := 0;
        chisq_yates_p := 1;
    END;
    EP_chisq_yates := chisq_yates;

    fisher_p := es_FisherExact(a, b, c, d);
    EP_fisher_p := fisher_p;
    fpt := es_FisherPt(a, b, c, d);      // v1.6: this table's own probability
    mcn := es_McNemar(b, c);             // v1.6: McNemar (the Paired reading)
    EP_MCN_P := mcn(2);

    IF p1 > 0 THEN
        phi := (a*d - b*c) / SQRT(p1);
    ELSE
        phi := 0;
    END;
    EP_phi := phi;
    EP_cramersV := ABS(phi);

    // For a 2x2 table, Pearson's r between the two binary variables IS phi --
    // signed. SQRT(chisq/N) returns |phi|, discarding the sign, so a negative
    // association displayed a POSITIVE "Pearson's r" directly beneath a negative
    // "Phi", and identical to Cramer's V (which is |phi| by definition). The
    // adjacent label "Phi^2 (= r^2)" already asserts r = +/-phi.
    // v1.4: this used to sit inside IF N > 0, but N == 0 already
    // returned at the top of the module, so the test was dead.
    pearsonR := phi;

    IF b*c > 0 THEN
        oddsR := (a*d)/(b*c);
    ELSE
        oddsR := 999;
    END;
    EP_OR := oddsR;
    EP_OR_CI := es_CalcORCI(a, b, c, d);

    IF r1 > 0 AND r2 > 0 THEN
        EP_risk_exp := a/r1;
        EP_risk_unexp := c/r2;
        IF EP_risk_unexp > 0 THEN
            RR := EP_risk_exp / EP_risk_unexp;
        ELSE
            RR := 999;
        END;
        RD := EP_risk_exp - EP_risk_unexp;
        EP_RR_CI := es_CalcRRCI(a, r1, c, r2);
        EP_RD_CI := es_CalcRDCI(a, r1, c, r2);
    ELSE
        RR := 999;
        // v1.7d (E4): with a row empty there is no risk in that row, so
        // there is no risk difference either.  v1.7c set RD := 0 and the
        // "CI" {-1, 1}, and the pages printed "RD = 0.0% (-100.0-100.0%)".
        // RD now carries the 999 sentinel, as RR does: es_FmtPct prints
        // it "undef", and EP_RD_CI {0, 0} is blanked by es_FmtCI.
        RD := 999;
        EP_risk_exp := 0;        // v1.4: were left from the previous run
        EP_risk_unexp := 0;
        EP_RR_CI := {0, 999};
        EP_RD_CI := {0, 0};
    END;
    EP_RR := RR;
    EP_RD := RD;

    // v1.7: the conditional MLE odds ratio and its exact CI (page 1's
    // cohort tab and page 7), once per table
    esCO := es_CondOR(a, b, c, d);
    esOOK := 0;                         // page 7's picture: on its first visit
    EP_OR_CMLE := 999; EP_OR_CMLE_CI := {0, 999};
    IF esCO(4) == 0 THEN EP_OR_CMLE := esCO(1); EP_OR_CMLE_CI := {esCO(2), esCO(3)}; END;
    IF esCO(4) == 1 THEN EP_OR_CMLE := 0; EP_OR_CMLE_CI := {0, esCO(3)}; END;
    IF esCO(4) == 2 THEN EP_OR_CMLE_CI := {esCO(2), 999}; END;
    // v1.7: the exact CIs of sensitivity a/(a+c) and specificity
    // d/(b+d) for the Diagnostic tab, once per table
    esX1 := {-1, -1, -1, -1};
    IF esBinOK == 1 THEN
        IF c1 > 0 THEN xcp := es_CPInt(a, c1, 0.025, 0); esX1(1) := xcp(1); esX1(2) := xcp(2); END;
        IF c2 > 0 THEN xcp := es_CPInt(d, c2, 0.025, 0); esX1(3) := xcp(1); esX1(4) := xcp(2); END;
    END;

    // v1.6: six pages.  1 the three readings (tabs), 2 the table and
    // its expected counts, 3 the tests (Fisher's point probability
    // added), 4 the Fisher bars, 5 the association measures with the
    // worked arithmetic (v1.5's page 3), 6 the mosaic (v1.5's page 4).
    // v1.7: and 7, the exact (conditional) odds ratio.
    // Taps turn pages (es_TurnPage); on page 1 a tap on a tab changes
    // the reading (esRd), on page 4 a tap on the plot picks a bar
    // (esQSel), on page 5 a tap on a row opens its arithmetic (esPick),
    // on page 6 a tap on a tile shows its numbers (esTile).  Up/Down do
    // the same from the keys.
    page := 1; esPick := 0; esLast := 0; esTile := 0;
    esRd := 1; esQOK := 0; esQSel := 0;     // a new table: work the bars out again
    REPEAT
        CASE
            IF page == 1 THEN
                es_ReadPage(a, b, c, d);
            END;
            IF page == 2 THEN
                es_Table2x2_Page1(a, b, c, d, r1, r2, c1, c2, N, ea, eb, ec, ed);
            END;
            IF page == 3 THEN
                es_Table2x2_Page2(chisq, chisq_p, chisq_yates, chisq_yates_p, fisher_p, N, fpt);
            END;
            IF page == 4 THEN
                es_HyperPage(a, b, c, d);
            END;
            IF page == 5 THEN
                es_Table2x2_Page3(oddsR, RR, RD, phi, pearsonR);
            END;
            IF page == 6 THEN
                es_Mosaic(a, b, c, d);
            END;
            // v1.7: the exact (conditional) odds ratio
            IF page == 7 THEN
                es_CORPage(a, b, c, d);
            END;
        END;
        // the worked-arithmetic box goes on top of page 5 (G1 still
        // holds the page, so the box is one more frame and one blit)
        IF page == 5 AND esPick > 0 THEN es_Arith(esPick, a, b, c, d); END;
        action := es_WaitKey();
        IF esPick > 0 THEN
            esPick := 0;                    // any key or tap closes the box
        ELSE
            IF action == 0 THEN BREAK; END;
            CASE
                IF action == 3 AND page == 1 THEN
                    esRd := esHit;          // a tab: 1 cohort, 2 diagnostic, 3 paired
                END;
                IF page == 1 AND (action == 4 OR action == 5) THEN
                    IF action == 5 THEN esRd := esRd + 1; ELSE esRd := esRd - 1; END;
                    IF esRd > 3 THEN esRd := 1; END;
                    IF esRd < 1 THEN esRd := 3; END;
                END;
                IF action == 3 AND page == 4 THEN
                    // the bar under the finger: the same esPL..esPR the
                    // bars are drawn with
                    IF esHit == 20 AND esQN > 0 THEN
                        esQSel := MIN(esQN, MAX(1, IP((esTX - esPL) * esQN / (esPR - esPL)) + 1));
                    END;
                END;
                IF page == 4 AND (action == 4 OR action == 5) THEN
                    IF esQN > 0 THEN
                        IF action == 5 THEN esQSel := esQSel + 1; ELSE esQSel := esQSel - 1; END;
                        esQSel := MIN(esQN, MAX(1, esQSel));
                    END;
                END;
                IF action == 3 AND page == 5 THEN
                    esPick := esHit; esLast := esHit;
                END;
                IF page == 5 AND (action == 4 OR action == 5) THEN
                    // the keyboard way in: Down opens OR, RR, RD in turn
                    IF action == 5 THEN esLast := esLast + 1; ELSE esLast := esLast - 1; END;
                    IF esLast > 3 THEN esLast := 1; END;
                    IF esLast < 1 THEN esLast := 3; END;
                    esPick := esLast;
                END;
                IF action == 3 AND page == 6 THEN
                    esTile := esHit;
                END;
                IF page == 6 AND (action == 4 OR action == 5) THEN
                    // and Down / Up step through the tiles a, b, c, d
                    IF action == 5 THEN esTile := esTile + 1; ELSE esTile := esTile - 1; END;
                    IF esTile > 4 THEN esTile := 1; END;
                    IF esTile < 1 THEN esTile := 4; END;
                END;
                DEFAULT
                    page := es_TurnPage(page, action, 7);   // v1.7: seven pages
            END;
        END;
    UNTIL 0;
END;


es_Table2x2_Page1(a, b, c, d, r1, r2, c1, c2, N, ea, eb, ec, ed)
BEGIN
    LOCAL y, x1, x2, x3;
    es_DrawHeader("2×2 TABLE ANALYSIS", "2/7");
    x1 := 100; x2 := 170; x3 := 240;
    y := 30;
    TEXTOUT_P("─── Observed Values ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Outcome+", G1, x1, y, 1, RGB(0,60,200));
    TEXTOUT_P("Outcome-", G1, x2, y, 1, RGB(0,60,200));
    TEXTOUT_P("Total", G1, x3, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Exposed+", G1, 10, y, 2, RGB(0,60,200));
    TEXTOUT_P(STRING(a,1,0), G1, x1+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(b,1,0), G1, x2+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(r1,1,0), G1, x3+10, y, 2, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Exposed-", G1, 10, y, 2, RGB(0,60,200));
    TEXTOUT_P(STRING(c,1,0), G1, x1+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(d,1,0), G1, x2+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(r2,1,0), G1, x3+10, y, 2, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Total", G1, 10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(c1,1,0), G1, x1+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(c2,1,0), G1, x2+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(N,1,0), G1, x3+10, y, 2, RGB(0,60,200));
    y := y + 22;
    TEXTOUT_P("─── Expected Values ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Outcome+", G1, x1, y, 1, RGB(90,90,90));
    TEXTOUT_P("Outcome-", G1, x2, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Exposed+", G1, 10, y, 2, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(ea, 2), G1, x1+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(eb, 2), G1, x2+10, y, 2, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Exposed-", G1, 10, y, 2, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(ec, 2), G1, x1+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(ed, 2), G1, x2+10, y, 2, RGB(90,90,90));
    y := y + 20;
    TEXTOUT_P("─── Cell Percentages ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("a/N: " + es_FmtPct(a/N), G1, 10, y, 1, RGB(90,90,90));
    TEXTOUT_P("b/N: " + es_FmtPct(b/N), G1, 90, y, 1, RGB(90,90,90));
    TEXTOUT_P("c/N: " + es_FmtPct(c/N), G1, 170, y, 1, RGB(90,90,90));
    TEXTOUT_P("d/N: " + es_FmtPct(d/N), G1, 250, y, 1, RGB(90,90,90));
    es_DrawFooter(1);
END;

es_Table2x2_Page2(chisq, chisq_p, chisq_yates, chisq_yates_p, fisher_p, N, fpt)
BEGIN
    LOCAL y, sig;
    es_DrawHeader("STATISTICAL TESTS", "3/7");
    // v1.4: re-spaced.  The N line used to land at y 210 (font 2 ends
    // at 222, on the footer).  Rows are 14 px apart; the last row,
    // N, is at y 196 and ends at 208.
    y := 30;
    TEXTOUT_P("─── Chi-Square Tests (df=1) ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("Pearson χ²:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(chisq, 4), G1, 140, y, 2, RGB(0,0,0));
    y := y + 14;
    IF chisq_p < 0.05 THEN sig := " *"; ELSE sig := ""; END;
    TEXTOUT_P("p-value:", G1, 30, y, 1, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(chisq_p, 5) + sig, G1, 140, y, 1,
              IFTE(chisq_p < 0.05, RGB(255,0,0), RGB(0,0,0)));
    y := y + 18;
    TEXTOUT_P("Yates' χ² (continuity corrected):", G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P(es_FmtNum(chisq_yates, 4), G1, 140, y, 2, RGB(0,0,0));
    y := y + 14;
    IF chisq_yates_p < 0.05 THEN sig := " *"; ELSE sig := ""; END;
    TEXTOUT_P("p-value:", G1, 30, y, 1, RGB(90,90,90));
    TEXTOUT_P(es_FmtNum(chisq_yates_p, 5) + sig, G1, 140, y, 1,
              IFTE(chisq_yates_p < 0.05, RGB(255,0,0), RGB(0,0,0)));
    // v1.6: two pixels tighter from here down, to make room for the
    // point probability (FEP's and ConTable2's second Fisher line)
    y := y + 18;
    TEXTOUT_P("─── Fisher's Exact Test ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    IF fisher_p >= 0 THEN
        IF fisher_p < 0.05 THEN sig := " *"; ELSE sig := ""; END;
        TEXTOUT_P("Two-sided exact p:", G1, 10, y, 2, RGB(0,0,0));
        TEXTOUT_P(es_FmtNum(fisher_p, 6) + sig, G1, 180, y, 2,
                  IFTE(fisher_p < 0.05, RGB(255,0,0), RGB(0,0,0)));
        y := y + 15;
        TEXTOUT_P("Point prob. (this table): " + es_FmtNum(fpt, 6) + "  (page 4)", G1, 30, y, 1, RGB(90,90,90), 284);
    ELSE
        TEXTOUT_P("N > 2000: Use χ² approximation", G1, 10, y, 2, RGB(90,90,90));
        y := y + 15;
    END;
    y := y + 15;
    TEXTOUT_P("─── Interpretation ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("* Significant at α = 0.05", G1, 10, y, 1, RGB(255,0,0));
    y := y + 14;
    TEXTOUT_P("N = " + STRING(N,1,0), G1, 10, y, 2, RGB(0,0,0));
    IF N < 20 THEN
        TEXTOUT_P("(Small N: prefer Fisher's exact)", G1, 80, y, 1, RGB(90,90,90));
    END;
    es_DrawFooter(1);
END;

es_Table2x2_Page3(oddsR, RR, RD, phi, pearsonR)
BEGIN
    LOCAL y;
    es_DrawHeader("ASSOCIATION MEASURES", "5/7");
    // v1.4: re-spaced.  Cramer's V used to sit at y 212 and the
    // Quick Reference block at y 232-256: under the footer and
    // below the screen, so nobody ever saw it.  Rows are now 14 px
    // apart (12 px font-2 rows, 2 px of air); Cramer's V is at y 190
    // and the Quick Reference is one small-font line at y 205, which
    // ends at 215 -- everything is above the footer at 222.  The full
    // text of the old reference lines is in Help > Risk & Odds Ratios.
    // v1.5: the OR, RR and RD rows (each with its CI line) are
    // tappable: es_AddHit records each row's box as it is drawn, and
    // a tap there opens the worked arithmetic (es_Arith).  The small
    // orange "(tap)" marks them.  The boxes stop at x 258, so a tap
    // at the right-hand edge, or anywhere below the three rows, still
    // turns the page.
    y := 30;
    TEXTOUT_P("─── Effect Measures ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    es_AddHit(1, 4, y - 2, 258, y + 25);
    TEXTOUT_P("Odds Ratio (OR):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(oddsR, 3), G1, 170, y, 2, RGB(0,140,60));
    TEXTOUT_P("(tap)", G1, 222, y + 2, 1, esORNG, 36);
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_OR_CI, 0), G1, 30, y, 1, RGB(90,90,90));
    y := y + 14;
    es_AddHit(2, 4, y - 2, 258, y + 25);
    TEXTOUT_P("Risk Ratio (RR):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(RR, 3), G1, 170, y, 2, RGB(0,140,60));
    TEXTOUT_P("(tap)", G1, 222, y + 2, 1, esORNG, 36);
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_RR_CI, 0), G1, 30, y, 1, RGB(90,90,90));
    y := y + 14;
    es_AddHit(3, 4, y - 2, 258, y + 25);
    TEXTOUT_P("Risk Difference (RD):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(RD), G1, 170, y, 2, RGB(0,0,0));
    TEXTOUT_P("(tap)", G1, 222, y + 2, 1, esORNG, 36);
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_RD_CI, 1), G1, 30, y, 1, RGB(90,90,90));
    y := y + 16;
    TEXTOUT_P("─── Correlation Measures ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Phi (φ):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(phi, 4), G1, 170, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Phi² (= r²):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(phi^2, 4), G1, 170, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Pearson's r:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(pearsonR, 4), G1, 170, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Cramer's V:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(ABS(phi), 4), G1, 170, y, 2, RGB(0,0,0));
    y := y + 15;
    TEXTOUT_P("OR or RR = 1 none, > 1 positive, < 1 protective", G1, 10, y, 1, RGB(90,90,90));
    es_Foot("tap a ratio = working, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 2: RISK ANALYSIS (Cohort Study Design)
// ═══════════════════════════════════════════════════════════════════════════

es_Module_RiskAnalysis()
BEGIN
    LOCAL a, b, c, d;
    LOCAL r1, r2, c1, c2, N;
    LOCAL Rexp, Runexp, Rtot;
    LOCAL oddsR, RR, RD, NNT;
    LOCAL AFe, AFp, ERR;
    LOCAL page, action;
    LOCAL inputOK;

    // v1.5: the touch grid replaces v1.4's INPUT form (Values or +
    // still opens it).  A cohort table has the same layout as module
    // 1: rows = exposed or not, columns = became a case or not.
    esMod := 2;
    esCel := esM2;
    inputOK := es_Grid("COHORT: COUNTS", {"Exposed+", "Exposed-"}, {"Outcome+", "Outcome-"});
    esM2 := esCel;
    IF inputOK == 0 THEN RETURN; END;
    a := esCel(1); b := esCel(2); c := esCel(3); d := esCel(4);

    // the grid never lets a negative or an empty table through; the
    // v1.4 checks stay as a guard
    IF a < 0 OR b < 0 OR c < 0 OR d < 0 THEN
        MSGBOX("Error: Values must be ≥ 0");
        RETURN;
    END;

    N := a + b + c + d;
    IF N == 0 THEN
        MSGBOX("Error: Table cannot be empty");
        RETURN;
    END;

    // v1.7d (E2): a risk is the cases in a row over the PEOPLE in that
    // row.  With a row empty (a + b = 0 or c + d = 0) that row has no
    // risk at all; v1.7c read it as 0 %, so the pages went on to "RD =
    // -50.0%" and "NNT (benefit) = 2.0".  Refused here, before any EP_*
    // result is set, as Incidence Rates refuses zero person-time.
    // (Module 1 still takes such a table: chi-square and Fisher hold.)
    IF a + b == 0 OR c + d == 0 THEN
        MSGBOX("A risk needs people in both rows: one row of the table is empty.");
        RETURN;
    END;

    EP_table := {a, b, c, d};
    EP_N := N;

    r1 := a + b;
    r2 := c + d;
    c1 := a + c;
    c2 := b + d;

    IF r1 > 0 THEN Rexp := a / r1; ELSE Rexp := 0; END;
    IF r2 > 0 THEN Runexp := c / r2; ELSE Runexp := 0; END;
    IF N > 0 THEN Rtot := c1 / N; ELSE Rtot := 0; END;

    EP_risk_exp := Rexp;
    EP_risk_unexp := Runexp;

    IF Runexp > 0 THEN
        RR := Rexp / Runexp;
        EP_RR := RR;
        EP_RR_CI := es_CalcRRCI(a, r1, c, r2);
    ELSE
        RR := 999;
        EP_RR := 999;
        EP_RR_CI := {0, 999};
    END;

    IF b > 0 AND c > 0 THEN
        oddsR := (a * d) / (b * c);
        EP_OR := oddsR;
        EP_OR_CI := es_CalcORCI(a, b, c, d);
    ELSE
        oddsR := 999;
        EP_OR := 999;
        EP_OR_CI := {0, 999};
    END;

    RD := Rexp - Runexp;
    EP_RD := RD;
    EP_RD_CI := es_CalcRDCI(a, r1, c, r2);

    // v1.7: the exact (Clopper-Pearson) CI of each risk, once per
    // table; page 4 draws it beside Wilson's
    EP_risk_exp_CP := {0, 0}; EP_risk_unexp_CP := {0, 0};
    IF esBinOK == 1 THEN
        IF r1 > 0 THEN EP_risk_exp_CP := es_CPInt(a, r1, 0.025, 0); END;
        IF r2 > 0 THEN EP_risk_unexp_CP := es_CPInt(c, r2, 0.025, 0); END;
    END;

    IF RD <> 0 THEN
        NNT := 1 / ABS(RD);
        EP_NNT := NNT;
        // v1.7d (E7): when |RD| is below 0.000001 the NNT is above
        // 1,000,000, no meaningful number of people to treat (and from
        // 1E12 on, STRING printed it in scientific form, "1.00E12").
        // Pages 3 and 5 say so, reading RD itself; NNT keeps its value
        // for them, and EP_NNT takes the 999 "undefined" sentinel that
        // Help > Variables names, as it already did for RD = 0.
        IF ABS(RD) < 0.000001 THEN EP_NNT := 999; END;
    ELSE
        NNT := 999;
        EP_NNT := 999;
    END;

    IF Rexp > 0 THEN
        AFe := (Rexp - Runexp) / Rexp;
        EP_AFe := AFe;
    ELSE
        AFe := 0;
        EP_AFe := 0;
    END;

    IF Rtot > 0 THEN
        AFp := (Rtot - Runexp) / Rtot;
        EP_AFp := AFp;
    ELSE
        AFp := 0;
        EP_AFp := 0;
    END;

    // v1.4: when RR is the 999 "undefined" sentinel, ERR stays 999 so
    // the display prints "undef" (it used to print 998 and 99800%).
    IF RR == 999 THEN ERR := 999; ELSE ERR := RR - 1; END;

    page := 1;
    REPEAT
        CASE
            IF page == 1 THEN
                es_Risk_Page1(Rexp, Runexp, Rtot, r1, r2, a, c);
            END;
            IF page == 2 THEN
                es_Risk_Page2(RR, oddsR, RD, ERR);
            END;
            IF page == 3 THEN
                es_Risk_Page3(NNT, AFe, AFp, RD);
            END;
            // v1.6: the risk bars and the NNT icon array
            IF page == 4 THEN
                es_RiskBars(a, b, c, d, RR, oddsR);
            END;
            IF page == 5 THEN
                es_NNTIcons(RD, NNT);
            END;
        END;
        action := es_WaitKey();
        IF action == 0 THEN BREAK; END;
        page := es_TurnPage(page, action, 5);   // v1.5: taps turn pages too
    UNTIL 0;
END;

es_Risk_Page1(Rexp, Runexp, Rtot, r1, r2, a, c)
BEGIN
    LOCAL y;
    es_DrawHeader("RISK ESTIMATES", "1/5");
    y := 32;
    TEXTOUT_P("─── Absolute Risks ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 20;
    TEXTOUT_P("Risk in Exposed (Re):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(Rexp), G1, 200, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("(" + STRING(a,1,0) + "/" + STRING(r1,1,0) + ")", G1, 200, y, 1, RGB(90,90,90));
    y := y + 20;
    TEXTOUT_P("Risk in Unexposed (Ru):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(Runexp), G1, 200, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("(" + STRING(c,1,0) + "/" + STRING(r2,1,0) + ")", G1, 200, y, 1, RGB(90,90,90));
    y := y + 20;
    TEXTOUT_P("Overall Risk (Rt):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(Rtot), G1, 200, y, 2, RGB(0,0,0));
    y := y + 26;
    TEXTOUT_P("─── Interpretation ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    // v1.4: the "x higher / x lower" ratio divides by the smaller
    // risk, which is 0 when one group has no events (c = 0 or a = 0).
    // That was a runtime division by zero in front of the class.  A
    // ratio against a zero risk is undefined, and saying so is the
    // teaching point.
    IF Rexp > Runexp THEN
        TEXTOUT_P("Exposure INCREASES risk", G1, 10, y, 2, RGB(255,0,0));
        y := y + 14;
        IF Runexp > 0 THEN
            TEXTOUT_P("Risk is " + es_FmtNum(Rexp/Runexp, 2) + "x higher in exposed", G1, 10, y, 1, RGB(90,90,90));
        ELSE
            TEXTOUT_P("Ratio undefined: no events in unexposed group", G1, 10, y, 1, RGB(90,90,90));
        END;
    ELSE
        IF Rexp < Runexp THEN
            TEXTOUT_P("Exposure DECREASES risk", G1, 10, y, 2, RGB(0,140,60));
            y := y + 14;
            IF Rexp > 0 THEN
                TEXTOUT_P("Risk is " + es_FmtNum(Runexp/Rexp, 2) + "x lower in exposed", G1, 10, y, 1, RGB(90,90,90));
            ELSE
                TEXTOUT_P("Ratio undefined: no events in exposed group", G1, 10, y, 1, RGB(90,90,90));
            END;
        ELSE
            TEXTOUT_P("No difference in risk", G1, 10, y, 2, RGB(0,0,0));
        END;
    END;
    es_DrawFooter(1);
END;

es_Risk_Page2(RR, oddsR, RD, ERR)
BEGIN
    LOCAL y;
    es_DrawHeader("COMPARATIVE MEASURES", "2/5");
    // v1.4: re-spaced.  The ERR line used to land at y 212 (font 2
    // ends at 224, on the footer); it is now at y 190.
    y := 30;
    TEXTOUT_P("─── Risk Ratio (Relative Risk) ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    // v1.7c: es_EqLb -- no "=" in the label when the value is "< 0.001"
    TEXTOUT_P(es_EqLb("RR = Re / Ru", es_FmtNum(RR, 3)), G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(RR, 3), G1, 150, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_RR_CI, 0), G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("─── Odds Ratio ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    // v1.7c: es_EqLb, as above
    TEXTOUT_P(es_EqLb("OR = (a×d)/(b×c)", es_FmtNum(oddsR, 3)), G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(oddsR, 3), G1, 180, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_OR_CI, 0), G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("─── Risk Difference ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("RD = Re - Ru =", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(RD), G1, 150, y, 2, IFTE(RD > 0, RGB(255,0,0), RGB(0,140,60)));
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_RD_CI, 1), G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("─── Excess Relative Risk ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    // v1.7c: es_EqLb, as above
    TEXTOUT_P(es_EqLb("ERR = RR - 1", es_FmtNum(ERR, 3)), G1, 10, y, 2, RGB(0,0,0));
    // v1.4: ERR carries the 999 sentinel when RR is undefined
    IF ERR == 999 THEN
        TEXTOUT_P("undef (RR undefined)", G1, 140, y, 2, RGB(0,0,0));
    ELSE
        TEXTOUT_P(es_FmtNum(ERR, 3) + " (" + es_FmtPct(ERR) + ")", G1, 140, y, 2, RGB(0,0,0));
    END;
    es_DrawFooter(1);
END;

es_Risk_Page3(NNT, AFe, AFp, RD)
BEGIN
    LOCAL y;
    es_DrawHeader("CLINICAL IMPACT", "3/5");
    y := 32;
    TEXTOUT_P("─── Number Needed to Treat ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    // v1.7d (E7): |RD| below 0.000001 (but not 0) gives an NNT above
    // 1,000,000, which tells a reader nothing, and from 1E12 on the
    // interpretation's STRING(CEILING(NNT), 1, 0) and es_FmtNum were in
    // scientific form ("For every 1.00000100011E12 exposed").  The line
    // reads "> 1,000,000", with no "=" before it, in its side's colour,
    // and says why.  Every other RD draws what v1.7c drew.
    IF RD <> 0 AND ABS(RD) < 0.000001 THEN
        IF RD > 0 THEN
            TEXTOUT_P("NNT (harm) = 1/RD", G1, 10, y, 2, RGB(0,0,0));
            TEXTOUT_P("> 1,000,000", G1, 180, y, 2, RGB(255,0,0));
        ELSE
            TEXTOUT_P("NNT (benefit) = 1/|RD|", G1, 10, y, 2, RGB(0,0,0));
            TEXTOUT_P("> 1,000,000", G1, 210, y, 2, RGB(0,140,60));
        END;
        y := y + 16;
        TEXTOUT_P("RD near 0 (|RD| < 0.000001): no meaningful NNT;", G1, 10, y, 1, RGB(90,90,90));
        y := y + 12;
        TEXTOUT_P("the two risks are practically equal", G1, 10, y, 1, RGB(90,90,90));
    ELSE
        IF RD > 0 THEN
            TEXTOUT_P("NNT (harm) = 1/RD =", G1, 10, y, 2, RGB(0,0,0));
            TEXTOUT_P(es_FmtNum(NNT, 1), G1, 180, y, 2, RGB(255,0,0));
            y := y + 16;
            TEXTOUT_P("Interpretation: For every " + STRING(CEILING(NNT),1,0) + " exposed,", G1, 10, y, 1, RGB(90,90,90));
            y := y + 12;
            TEXTOUT_P("1 additional case occurs due to exposure", G1, 10, y, 1, RGB(90,90,90));
        ELSE
            IF RD < 0 THEN
                TEXTOUT_P("NNT (benefit) = 1/|RD| =", G1, 10, y, 2, RGB(0,0,0));
                TEXTOUT_P(es_FmtNum(NNT, 1), G1, 210, y, 2, RGB(0,140,60));
                y := y + 16;
                TEXTOUT_P("Interpretation: Treat " + STRING(CEILING(NNT),1,0) + " to prevent", G1, 10, y, 1, RGB(90,90,90));
                y := y + 12;
                TEXTOUT_P("1 case", G1, 10, y, 1, RGB(90,90,90));
            ELSE
                TEXTOUT_P("NNT: undefined (RD = 0)", G1, 10, y, 2, RGB(90,90,90));
            END;
        END;
    END;
    y := y + 26;
    TEXTOUT_P("─── Attributable Fractions ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("AF (exposed) = (Re-Ru)/Re:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(AFe), G1, 230, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("% of cases in exposed due to exposure", G1, 20, y, 1, RGB(90,90,90));
    y := y + 20;
    TEXTOUT_P("AF (population) = (Rt-Ru)/Rt:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(AFp), G1, 230, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("% of all cases due to exposure", G1, 20, y, 1, RGB(90,90,90));
    y := y + 20;
    IF RD < 0 THEN
        TEXTOUT_P("(Negative RD suggests protective effect)", G1, 10, y, 1, RGB(0,140,60));
    END;
    es_DrawFooter(1);
END;

// PAGE 4 of module 2 (v1.6): THE TWO RISKS AS BARS.  Each bar is a
// group's risk, the whisker its 95 % Wilson CI (es_WilsonCI, the
// interval module 3 uses for proportions); the magenta bracket on
// the right joins the two bar tops, and its length is the risk
// difference.  Under the plot: how far the odds ratio overstates the
// risk ratio, from the data: OR / RR = (1 - Ru) / (1 - Re), so the
// two agree only when the outcome is rare in both groups.
es_RiskBars(rbA, rbB, rbC, rbD, rbRR, rbOR)
BEGIN
    LOCAL rbN1, rbN0, rbRe, rbRu, rbCe, rbCu;
    LOCAL rbTop, rbStp, rbV, rbY, rbYe, rbYu;
    LOCAL rbI, rbX, rbS, rbPc, rbYl, rbYh;
    LOCAL rbP, rbNm, rbK, rbL1, rbL2, rbRf;
    LOCAL rbXe, rbXu, rbXl, rbXh;
    rbN1 := rbA + rbB; rbN0 := rbC + rbD;
    es_DrawHeader("RISK BARS", "4/5");
    IF rbN1 == 0 OR rbN0 == 0 THEN
        TEXTOUT_P("Both rows need people: a risk is the cases", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("in a row divided by the people in it.", G1, 12, 58, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN 0;
    END;
    rbRe := rbA / rbN1; rbRu := rbC / rbN0;
    rbCe := es_WilsonCI(rbA, rbN1); rbCu := es_WilsonCI(rbC, rbN0);
    // v1.7: the exact CIs, worked out at module entry
    rbXe := EP_risk_exp_CP; rbXu := EP_risk_unexp_CP;
    // ---- the y scale: 0 at y 150, rbTop at y 40 ----
    rbTop := MAX(MAX(rbCe(2), rbCu(2)), 0.05);
    IF esBinOK == 1 THEN rbTop := MAX(rbTop, MAX(rbXe(2), rbXu(2))); END;
    rbStp := es_Nice(rbTop / 4);
    rbTop := CEILING(rbTop / rbStp - 0.000001) * rbStp;
    rbV := 0;
    WHILE rbV <= rbTop + rbStp * 0.001 DO
        rbY := 150 - IP(rbV / rbTop * 110 + 0.5);
        LINE_P(G1, 40, rbY, 214, rbY, esGRID);
        TEXTOUT_P(STRING(100 * rbV, 2, MAX(0, -FLOOR(LOG(100 * rbStp)))) + "%", G1, 2, rbY - 5, 1, esGRAY, 36);
        rbV := rbV + rbStp;
    END;
    LINE_P(G1, 40, 38, 40, 150, #000000h);
    LINE_P(G1, 40, 150, 214, 150, #000000h);
    // ---- the two bars, exposed at x 60, unexposed at x 140 ----
    FOR rbI FROM 1 TO 2 DO
        IF rbI == 1 THEN
            rbP := rbRe; rbYl := rbCe(1); rbYh := rbCe(2); rbX := 60; rbNm := "Exposed+";
            rbS := STRING(rbA, 2, 0) + "/" + STRING(rbN1, 2, 0);
            rbXl := rbXe(1); rbXh := rbXe(2);
        ELSE
            rbP := rbRu; rbYl := rbCu(1); rbYh := rbCu(2); rbX := 140; rbNm := "Exposed-";
            rbS := STRING(rbC, 2, 0) + "/" + STRING(rbN0, 2, 0);
            rbXl := rbXu(1); rbXh := rbXu(2);
        END;
        rbY := 150 - IP(rbP / rbTop * 110 + 0.5);
        IF rbY < 150 THEN RECT_P(G1, rbX, rbY, rbX + 50, 150, esBLUE, esMBLU); END;
        // the 95 % Wilson CI as a black whisker left of the bar's
        // middle (v1.7: moved 6 px left to make room) ...
        rbYl := 150 - IP(rbYl / rbTop * 110 + 0.5);
        rbYh := 150 - IP(rbYh / rbTop * 110 + 0.5);
        LINE_P(G1, rbX + 19, rbYl, rbX + 19, rbYh, #000000h);
        LINE_P(G1, rbX + 14, rbYl, rbX + 24, rbYl, #000000h);
        LINE_P(G1, rbX + 14, rbYh, rbX + 24, rbYh, #000000h);
        // ... and v1.7: the exact (Clopper-Pearson) CI in orange to its
        // right -- the longer of the two, since exact never covers less
        // than 95 %
        IF esBinOK == 1 THEN
            rbXl := 150 - IP(rbXl / rbTop * 110 + 0.5);
            rbXh := 150 - IP(rbXh / rbTop * 110 + 0.5);
            LINE_P(G1, rbX + 31, rbXl, rbX + 31, rbXh, esORNG);
            LINE_P(G1, rbX + 32, rbXl, rbX + 32, rbXh, esORNG);
            LINE_P(G1, rbX + 26, rbXl, rbX + 37, rbXl, esORNG);
            LINE_P(G1, rbX + 26, rbXh, rbX + 37, rbXh, esORNG);
            rbYh := MIN(rbYh, rbXh);
        END;
        TEXTOUT_P(es_FmtPct(rbP), G1, rbX + 8, MAX(27, rbYh - 12), 1, esBLUE, 60);
        TEXTOUT_P(rbNm, G1, rbX - 2, 153, 1, esBLUE, 66);
        TEXTOUT_P(rbS, G1, rbX + 4, 165, 1, esGRAY, 60);
        IF rbI == 1 THEN rbYe := rbY; ELSE rbYu := rbY; END;
    END;
    // ---- the risk difference: a bracket joining the two bar tops ----
    FOR rbK FROM 112 TO 222 STEP 3 DO PIXON_P(G1, rbK, rbYe, esMAG); END;
    FOR rbK FROM 192 TO 222 STEP 3 DO PIXON_P(G1, rbK, rbYu, esMAG); END;
    LINE_P(G1, 222, rbYe, 222, rbYu, esMAG);
    LINE_P(G1, 218, rbYe, 222, rbYe, esMAG);
    LINE_P(G1, 218, rbYu, 222, rbYu, esMAG);
    rbY := IP((rbYe + rbYu) / 2);
    TEXTOUT_P("RD =", G1, 228, rbY - 11, 1, esMAG, 88);
    TEXTOUT_P(es_FmtPct(rbRe - rbRu), G1, 228, rbY + 1, 1, esMAG, 88);
    // v1.7: the legend names both whiskers
    IF esBinOK == 1 THEN
        TEXTOUT_P("95% CIs: black = Wilson, orange = exact (C-P)", G1, 6, 180, 1, esGRAY, 308);
    ELSE
        TEXTOUT_P("Bars = risk, whiskers = 95% Wilson CI.", G1, 6, 180, 1, esGRAY, 308);
    END;
    // ---- OR against RR, computed from these data ----
    IF rbRR == 999 OR rbOR == 999 OR rbRe == 0 OR rbRu == 0 THEN
        rbL1 := "OR vs RR: not compared (a ratio is 0 or";
        rbL2 := "undefined because of a zero count).";
    ELSE
        // name the risk that makes the outcome common: Ru, or Re if Ru is small
        rbRf := "Ru = " + STRING(ROUND(100 * rbRu, 1), 2, 1) + " %";
        IF rbRu < 0.1 AND rbRe >= 0.1 THEN rbRf := "Re = " + STRING(100 * rbRe, 2, 1) + " %"; END;
        IF ABS(rbRR - 1) < 0.0000001 THEN
            rbL1 := "RR = OR = 1: no association, so there is";
            rbL2 := "nothing for the OR to overstate.";
        ELSE
            IF rbRR > 1 THEN
                rbPc := 100 * (rbOR / rbRR - 1);
                rbS := " overstates RR = ";
            ELSE
                rbPc := 100 * (1 - rbOR / rbRR);
                rbS := " exaggerates RR = ";
            END;
            IF MAX(rbRe, rbRu) >= 0.1 THEN
                rbL1 := "OR = " + STRING(ROUND(rbOR, 2), 2, 2) + rbS + STRING(ROUND(rbRR, 2), 2, 2) + " by " + STRING(ROUND(rbPc, 0), 2, 0) + " % here";
                rbL2 := "because the outcome is common (" + rbRf + ").";
            ELSE
                rbL1 := "OR = " + STRING(ROUND(rbOR, 2), 2, 2) + rbS + STRING(ROUND(rbRR, 2), 2, 2) + " by only " + STRING(ROUND(rbPc, 0), 2, 0) + " %:";
                rbL2 := "the outcome is rare here (Ru = " + STRING(ROUND(100 * rbRu, 1), 2, 1) + " %).";
            END;
        END;
    END;
    TEXTOUT_P(rbL1, G1, 6, 193, 1, #000000h, 308);
    TEXTOUT_P(rbL2, G1, 6, 205, 1, #000000h, 308);
    es_DrawFooter(1);
    RETURN 1;
END;

// PAGE 5 of module 2 (v1.6): THE NNT AS 100 PEOPLE.  CEILING(NNT)
// cells (the usual rounding up, as page 3 does) are the group that
// is exposed or treated; one of them is coloured: red for the one
// extra case the exposure causes (RD > 0), green for the one case
// the treatment prevents (RD < 0).  The rest of the 100 are grey, for
// scale.  An NNT above 100 cannot fit: the page says so.
es_NNTIcons(niRD, niNNT)
BEGIN
    LOCAL niM, niI, niR, niC, niX, niY;
    LOCAL niF, niK, niS;
    es_DrawHeader("NNT: ICON ARRAY", "5/5");
    IF niRD == 0 OR niNNT == 999 THEN
        TEXTOUT_P("RD = 0: the risks are equal, so the NNT is", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("undefined (it would be 1/0).", G1, 12, 58, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN 0;
    END;
    // v1.7d (E7): |RD| below 0.000001 gives an NNT above 1,000,000: no
    // icon array can show it, and from 1E12 on STRING printed it in
    // scientific form (1 999999 1 1000000 drew "NNT = 1/< 0.000001 =
    // 1.00E12, round up: 1E12" and "Expose 1E12 (blue)").  The page
    // says so instead and draws no cells, as it does for RD = 0.
    IF ABS(niRD) < 0.000001 THEN
        TEXTOUT_P("NNT > 1,000,000 (RD near 0: no meaningful NNT).", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("The two risks differ by less than 0.000001, so", G1, 12, 58, 1, #000000h, 296);
        TEXTOUT_P("no icon array is drawn.", G1, 12, 72, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN 0;
    END;
    niM := CEILING(niNNT);
    FOR niI FROM 1 TO 100 DO
        niR := IP((niI - 1) / 10);
        niC := niI - 1 - 10 * niR;
        niX := 90 + 14 * niC;
        niY := 32 + 14 * niR;
        niF := esGRID; niK := #C8C8C8h;                       // the rest of 100
        IF niI <= niM THEN niF := esMBLU; niK := esBLUE; END;  // the NNT group
        IF niI == MIN(niM, 100) THEN
            IF niRD > 0 THEN niF := esRED; niK := esRED; ELSE niF := esGRN; niK := esGRN; END;
        END;
        RECT_P(G1, niX, niY, niX + 11, niY + 11, niK, niF);
    END;
    // v1.7b: |RD| below 0.001 would print in scientific form with fixed
    // decimals (emulator, 17 Oct 2026), and 0.000 would read as 1/0, so
    // a small RD is shown to 3 significant figures (es_SigTxt); the
    // line then leaves out "|RD| = " to stay inside the screen
    IF ABS(niRD) < 0.001 THEN
        niS := "NNT = 1/" + es_SigTxt(ABS(niRD));
    ELSE
        niS := "NNT = 1/|RD| = 1/" + STRING(ABS(niRD), 2, 3);
    END;
    TEXTOUT_P(niS + " = " + STRING(niNNT, 2, 2) + ", round up: " + STRING(niM, 2, 0), G1, 6, 176, 1, #000000h, 308);
    IF niRD > 0 THEN
        niS := "Expose " + STRING(niM, 2, 0) + " (blue): about 1 extra case (red).";
        TEXTOUT_P(niS, G1, 6, 189, 1, esRED, 308);
    ELSE
        niS := "Treat " + STRING(niM, 2, 0) + " (blue): 1 case prevented (green).";
        TEXTOUT_P(niS, G1, 6, 189, 1, esGRN, 308);
    END;
    IF niM > 100 THEN
        TEXTOUT_P("NNT > 100: only the first 100 are drawn.", G1, 6, 202, 1, esGRAY, 308);
    ELSE
        TEXTOUT_P("Grey = the rest of 100 people, for scale.", G1, 6, 202, 1, esGRAY, 308);
    END;
    es_DrawFooter(1);
    RETURN 1;
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 3: SENSITIVITY & SPECIFICITY
// ═══════════════════════════════════════════════════════════════════════════

// v1.5: PPV and NPV at ANY prevalence, from Bayes' theorem.  The
// test's sensitivity and specificity stay fixed; only p (the
// prevalence, the share of the tested group who have the disease)
// changes:
//   PPV = sens·p / (sens·p + (1 - spec)(1 - p))
//   NPV = spec(1 - p) / (spec(1 - p) + (1 - sens)·p)
// At the sample's own prevalence p = (a+c)/N these give back a/(a+b)
// and d/(c+d) exactly.  Each returns -1 when the ratio is 0/0 (for
// example PPV at p = 0 for a test with specificity 100 %: nobody
// tests positive), and -1 is never drawn.
es_PPV(pvSe, pvSp, pvP)
BEGIN
    LOCAL pvD;
    pvD := pvSe * pvP + (1 - pvSp) * (1 - pvP);
    IF pvD <= 0 THEN RETURN -1; END;
    RETURN pvSe * pvP / pvD;
END;

es_NPV(nvSe, nvSp, nvP)
BEGIN
    LOCAL nvD;
    nvD := nvSp * (1 - nvP) + (1 - nvSe) * nvP;
    IF nvD <= 0 THEN RETURN -1; END;
    RETURN nvSp * (1 - nvP) / nvD;
END;

// A value in 0..1 as a pixel row of the prevalence plot (y grows
// DOWN the screen, so 1 is at the top, esPT); -1 stays -1.
es_PixY(pyV)
BEGIN
    IF pyV < 0 THEN RETURN -1; END;
    RETURN esPB - IP(pyV * (esPB - esPT) + 0.5);
END;

// A proportion as "16.1 %", or "undef".
// v1.7b: rounded first (a PPV of 0.04 % read "4.0ᴇ-2 %").
es_PctTxt(ptV)
BEGIN
    IF ptV < 0 THEN RETURN "undef"; END;
    RETURN STRING(ROUND(100 * ptV, 1), 2, 1) + " %";
END;

// One frame of the prevalence page (page 4 of 5), at prevalence
// esPv.  The two curves come from the caches esPPY / esNPY that
// es_PrevPage filled once, so a frame costs two LINE_P per pixel
// column and no arithmetic per column -- cheap enough to redraw on
// every sample of a dragging finger, even on a G1.
//   magenta  PPV: P(disease | test +)
//   blue     NPV: P(no disease | test -)
//   red      the sample's own prevalence ("you are here")
//   grey     the prevalence being looked at (the finger)
es_PrevDraw(pdSe, pdSp, pdP0)
BEGIN
    LOCAL pdI, pdX, pdY, pdPPV, pdNPV, pdXs;
    LOCAL pdXc, pdW, pdS;
    pdW := esPR - esPL;                       // 272 pixel columns
    pdPPV := es_PPV(pdSe, pdSp, esPv);
    pdNPV := es_NPV(pdSe, pdSp, esPv);
    es_DrawHeader("PPV VS PREVALENCE", "4/6");
    // ---- the two numbers, big, in their curves' colours ----
    TEXTOUT_P("PPV = " + es_PctTxt(pdPPV), G1, 6, 27, 5, esMAG, 154);
    TEXTOUT_P("NPV = " + es_PctTxt(pdNPV), G1, 164, 27, 5, esFOOT, 152);
    TEXTOUT_P("sens = " + STRING(ROUND(100 * pdSe, 1), 2, 1) + " %, spec = " + STRING(ROUND(100 * pdSp, 1), 2, 1) + " %", G1, 6, 49, 1, esGRAY, 200);
    TEXTOUT_P("sample: " + STRING(ROUND(100 * pdP0, 1), 2, 1) + " %", G1, 212, 49, 1, esRED, 104);
    // ---- grid, ticks and labels: 0, 25, 50, 75, 100 % both ways ----
    FOR pdI FROM 0 TO 4 DO
        pdY := esPB - IP(pdI * (esPB - esPT) / 4 + 0.5);
        LINE_P(G1, esPL, pdY, esPR, pdY, esGRID);
        TEXTOUT_P(STRING(25 * pdI, 2, 0), G1, 6, pdY - 5, 1, esGRAY, 26);
        pdX := esPL + IP(pdI * pdW / 4 + 0.5);
        LINE_P(G1, pdX, esPT, pdX, esPB, esGRID);
        TEXTOUT_P(STRING(25 * pdI, 2, 0) + "%", G1, MIN(pdX - 8, 290), esPB + 8, 1, esGRAY, 30);
    END;
    // ---- the two curves, pixel column by pixel column, from the cache ----
    FOR pdI FROM 1 TO pdW DO
        pdX := esPL + pdI;
        IF esPPY(pdI) >= 0 AND esPPY(pdI + 1) >= 0 THEN
            LINE_P(G1, pdX - 1, esPPY(pdI), pdX, esPPY(pdI + 1), esMAG);
        END;
        IF esNPY(pdI) >= 0 AND esNPY(pdI + 1) >= 0 THEN
            LINE_P(G1, pdX - 1, esNPY(pdI), pdX, esNPY(pdI + 1), esFOOT);
        END;
    END;
    LINE_P(G1, esPL, esPT, esPL, esPB, #000000h);       // the axes
    LINE_P(G1, esPL, esPB, esPR, esPB, #000000h);
    // ---- the prevalence being looked at: a grey line, a dot on each
    //      curve, and the value riding along at the top ----
    pdXc := esPL + IP(esPv * pdW + 0.5);
    LINE_P(G1, pdXc, esPT, pdXc, esPB, esGRAY);
    pdY := es_PixY(pdPPV);
    IF pdY >= 0 THEN RECT_P(G1, pdXc - 3, pdY - 3, pdXc + 3, pdY + 3, esMAG, esMAG); END;
    pdY := es_PixY(pdNPV);
    IF pdY >= 0 THEN RECT_P(G1, pdXc - 3, pdY - 3, pdXc + 3, pdY + 3, esFOOT, esFOOT); END;
    pdS := "p = " + STRING(ROUND(100 * esPv, 1), 2, 1) + " %";
    IF pdXc > esPR - 76 THEN
        TEXTOUT_P(pdS, G1, pdXc - 74, esPT + 2, 1, #000000h, 72);
    ELSE
        TEXTOUT_P(pdS, G1, pdXc + 4, esPT + 2, 1, #000000h, 72);
    END;
    // ---- you are here: the sample's own prevalence, in red ----
    pdXs := esPL + IP(pdP0 * pdW + 0.5);
    RECT_P(G1, pdXs - 2, esPB + 1, pdXs + 2, esPB + 6, esRED, esRED);
    pdY := es_PixY(es_PPV(pdSe, pdSp, pdP0));
    IF pdY >= 0 THEN RECT_P(G1, pdXs - 2, pdY - 2, pdXs + 2, pdY + 2, esRED, esRED); END;
    pdY := es_PixY(es_NPV(pdSe, pdSp, pdP0));
    IF pdY >= 0 THEN RECT_P(G1, pdXs - 2, pdY - 2, pdXs + 2, pdY + 2, esRED, esRED); END;
    // ---- the sentence the page exists for ----
    TEXTOUT_P("Same test: at prevalence " + STRING(ROUND(100 * esPv, 1), 2, 1) + " % a positive is", G1, 6, 169, 1, #000000h, 308);
    IF pdPPV >= 0 AND pdNPV >= 0 THEN
        TEXTOUT_P("right " + es_PctTxt(pdPPV) + " of the time, a negative " + es_PctTxt(pdNPV) + ".", G1, 6, 181, 1, #000000h, 308);
    ELSE
        TEXTOUT_P("(PPV or NPV is 0/0 here: undefined)", G1, 6, 181, 1, esGRAY, 308);
    END;
    // ---- the slider: the same x scale as the plot ----
    RECT_P(G1, esPL, esSLT, esPR, esSLB, esGRAY, esLBLU);
    IF pdXc > esPL THEN RECT_P(G1, esPL, esSLT, pdXc, esSLB, esBLUE, esMBLU); END;
    LINE_P(G1, pdXs, esSLT - 4, pdXs, esSLB + 4, esRED);
    RECT_P(G1, pdXc - 3, esSLT - 5, pdXc + 3, esSLB + 5, esBLUE, esBLUE);
    es_Foot("drag or " + CHAR(8592) + CHAR(8594) + " = prevalence, +/- = page, Esc = menu");
END;

// PAGE 4 of module 3 (v1.5): PPV and NPV against prevalence, with a
// live drag.  While a finger is down on the plot or on the slider
// the prevalence follows it and the frame is redrawn for every new
// pixel column it reaches; the arrows step 1 % (Up/Down 10 %), 0 goes
// back to the sample's own prevalence.  The page runs its own loop
// and returns what the page loop should do next, in es_WaitKey's
// codes: 0 Esc, -1 / 1 the - / + keys (Enter = +), -2 / 2 a tap on
// the left / right half outside the plot and the slider.
// ppOK = 0 when the table has an empty column: then sensitivity or
// specificity is undefined and the page only says so.
es_PrevPage(ppSe, ppSp, ppP0, ppOK)
BEGIN
    LOCAL ppK, ppM, ppM1, ppGo, ppAct, ppX;
    LOCAL ppLX, ppI, ppW, ppZn;
    IF ppOK == 0 THEN
        es_DrawHeader("PPV VS PREVALENCE", "4/6");
        TEXTOUT_P("The curves need a sensitivity AND a", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("specificity: type at least one Disease+", G1, 12, 58, 1, #000000h, 296);
        TEXTOUT_P("and one Disease- count in the grid.", G1, 12, 72, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN es_WaitKey();
    END;
    // ---- the caches: both curves ONCE per visit, one value per pixel
    //      column.  Sens and spec do not change while the finger
    //      drags, so the drag only has to redraw.  MAKELIST with K is
    //      the form NormExplorer and ChiTable use (it writes Home's K).
    ppW := esPR - esPL;
    esPPY := MAKELIST(0, K, 0, ppW, 1);
    esNPY := MAKELIST(0, K, 0, ppW, 1);
    FOR ppI FROM 0 TO ppW DO
        esPPY(ppI + 1) := es_PixY(es_PPV(ppSe, ppSp, ppI / ppW));
        esNPY(ppI + 1) := es_PixY(es_NPV(ppSe, ppSp, ppI / ppW));
    END;
    ppGo := 1; ppAct := 0;
    REPEAT ppK := GETKEY; UNTIL ppK == -1;      // drain the key or tap that
    es_Drain();                                 // brought us here
    WHILE ppGo == 1 DO
        es_PrevDraw(ppSe, ppSp, ppP0);
        REPEAT
            ppK := GETKEY;
            ppM := MOUSE;
            ppM1 := ppM(1);
        UNTIL ppK > -1 OR SIZE(ppM1) > 0;
        IF SIZE(ppM1) > 0 THEN
            // a finger is down (MOUSE list: {x, y, x0, y0, type}).
            // On the plot or the slider it sets the prevalence; anywhere
            // else it is a page turn, as on every other page.
            ppZn := 0;
            IF ppM1(2) >= esPT - 6 AND ppM1(2) <= esPB + 6 THEN ppZn := 1; END;
            IF ppM1(2) >= esSLT - 12 AND ppM1(2) < 218 THEN ppZn := 1; END;
            IF ppZn == 1 THEN
                // the live drag: the first sample is the press, later
                // ones the drag; x maps through the SAME esPL..esPR the
                // drawing uses.  Redraw only when the column changes.
                // The prevalence is rounded to 0.1 %, the precision the
                // page prints (one pixel is about 0.37 %), so a student
                // who checks the digits by hand gets the same answer.
                // Esc ends a drag whose touch reading sticks.
                ppLX := -1;
                REPEAT
                    ppX := MIN(MAX(ppM1(1), esPL), esPR);
                    IF ppX <> ppLX THEN
                        esPv := ROUND((ppX - esPL) / ppW, 3);
                        es_PrevDraw(ppSe, ppSp, ppP0);
                        ppLX := ppX;
                    END;
                    ppM := MOUSE;
                    ppM1 := ppM(1);
                UNTIL SIZE(ppM1) == 0 OR GETKEY == 4;
                es_Drain();
            ELSE
                es_Drain();
                IF ppM1(1) < 160 THEN ppAct := -2; ELSE ppAct := 2; END;
                ppGo := 0;
            END;
        ELSE
            // the keyboard does everything the finger does
            IF ppK == 7 THEN esPv := MAX(0, ROUND(100 * esPv, 0) / 100 - 0.01); END;
            IF ppK == 8 THEN esPv := MIN(1, ROUND(100 * esPv, 0) / 100 + 0.01); END;
            IF ppK == 12 THEN esPv := MAX(0, ROUND(100 * esPv, 0) / 100 - 0.1); END;
            IF ppK == 2 THEN esPv := MIN(1, ROUND(100 * esPv, 0) / 100 + 0.1); END;
            IF ppK == 42 THEN esPv := ppP0; END;                  // 0: your sample
            IF ppK == 4 THEN ppAct := 0; ppGo := 0; END;          // Esc: menu
            IF ppK == 50 OR ppK == 30 THEN ppAct := 1; ppGo := 0; END;   // + or Enter: on
            IF ppK == 45 THEN ppAct := -1; ppGo := 0; END;        // -: back
        END;
    END;
    REPEAT ppK := GETKEY; UNTIL ppK == -1;
    RETURN ppAct;
END;

// PAGE 5 of module 3 (v1.5): the test as ONE point on the ROC square.
// x = 1 - specificity (the false-positive rate), y = sensitivity (the
// true-positive rate).  The dotted diagonal is a test no better than
// chance.  The line from (0,0) through the point has slope
// sens / (1 - spec) = LR+; the line from (1,1) through it has slope
// (1 - sens) / spec = LR-.  A steep LR+ line and a flat LR- line
// mean a test whose results move the odds a lot.
es_ROCPage(rcSe, rcSp, rcLRp, rcLRn, rcOK)
BEGIN
    LOCAL rcX0, rcX1, rcY0, rcY1, rcI, rcFx;
    LOCAL rcPx, rcPy, rcM, rcEx, rcEy, rcLx;
    LOCAL rcLy;
    es_DrawHeader("ROC POINT", "5/6");
    IF rcOK == 0 THEN
        TEXTOUT_P("The point needs a sensitivity AND a", G1, 12, 44, 1, #000000h, 296);
        TEXTOUT_P("specificity: type at least one Disease+", G1, 12, 58, 1, #000000h, 296);
        TEXTOUT_P("and one Disease- count in the grid.", G1, 12, 72, 1, #000000h, 296);
        es_DrawFooter(1);
        RETURN 0;
    END;
    // the unit square, 120 px a side: x from rcX0 (0) to rcX1 (1),
    // y from rcY0 (0, the bottom) up to rcY1 (1, the top)
    rcX0 := 40; rcX1 := 160; rcY0 := 154; rcY1 := 34;
    RECT_P(G1, rcX0, rcY1, rcX1, rcY0, #000000h, #FFFFFFh);
    FOR rcI FROM 1 TO 3 DO
        LINE_P(G1, rcX0 + 30 * rcI, rcY1 + 1, rcX0 + 30 * rcI, rcY0 - 1, esGRID);
        LINE_P(G1, rcX0 + 1, rcY0 - 30 * rcI, rcX1 - 1, rcY0 - 30 * rcI, esGRID);
    END;
    // the chance diagonal, dotted (LINE_P has no dash style)
    FOR rcI FROM 0 TO 120 STEP 3 DO
        PIXON_P(G1, rcX0 + rcI, rcY0 - rcI, esGRAY);
    END;
    TEXTOUT_P("0", G1, rcX0 - 3, rcY0 + 3, 1, esGRAY, 10);
    TEXTOUT_P("1", G1, rcX1 - 3, rcY0 + 3, 1, esGRAY, 10);
    TEXTOUT_P("1 - spec (FPR)", G1, rcX0 + 22, rcY0 + 3, 1, esGRAY, 96);
    TEXTOUT_P("1", G1, rcX0 - 10, rcY1 - 4, 1, esGRAY, 10);
    TEXTOUT_P("sens", G1, 4, 82, 1, esGRAY, 34);
    TEXTOUT_P("(TPR)", G1, 2, 95, 1, esGRAY, 36);
    rcFx := 1 - rcSp;
    rcPx := rcX0 + IP(rcFx * 120 + 0.5);
    rcPy := rcY0 - IP(rcSe * 120 + 0.5);
    // ---- LR+: from (0,0) through the point to the edge of the square.
    //      Dividing the point by its larger coordinate puts it on the
    //      edge without changing the slope.
    rcM := MAX(rcFx, rcSe);
    IF rcM > 0 THEN
        rcEx := rcX0 + ROUND(rcFx / rcM * 120, 0);
        rcEy := rcY0 - ROUND(rcSe / rcM * 120, 0);
        LINE_P(G1, rcX0, rcY0, rcEx, rcEy, esFOOT);
        rcLx := rcX0 + ROUND(0.6 * (rcPx - rcX0), 0) + 5;
        rcLy := rcY0 + ROUND(0.6 * (rcPy - rcY0), 0) - 4;
        TEXTOUT_P("LR+", G1, rcLx, rcLy, 1, esFOOT, 24);
    END;
    // ---- LR-: from (1,1) through the point, the same way ----
    rcM := MAX(rcSp, 1 - rcSe);
    IF rcM > 0 THEN
        rcEx := rcX1 + ROUND((rcFx - 1) / rcM * 120, 0);
        rcEy := rcY1 - ROUND((rcSe - 1) / rcM * 120, 0);
        LINE_P(G1, rcX1, rcY1, rcEx, rcEy, esMAG);
        rcLx := rcX1 + ROUND(0.6 * (rcPx - rcX1), 0) - 10;
        rcLy := rcY1 + ROUND(0.6 * (rcPy - rcY1), 0) + 4;
        TEXTOUT_P("LR-", G1, rcLx, rcLy, 1, esMAG, 24);
    END;
    // ---- the test itself: the red dot, drawn last ----
    RECT_P(G1, rcPx - 3, rcPy - 3, rcPx + 3, rcPy + 3, esRED, esRED);
    // ---- the numbers beside the square ----
    TEXTOUT_P("Red dot = this test", G1, 170, 30, 1, esRED, 146);
    TEXTOUT_P("TPR = sens = " + STRING(ROUND(100 * rcSe, 1), 2, 1) + " %", G1, 170, 43, 1, #000000h, 146);
    TEXTOUT_P("FPR = 1-spec = " + STRING(ROUND(100 * rcFx, 1), 2, 1) + " %", G1, 170, 56, 1, #000000h, 146);
    TEXTOUT_P("LR+ = slope from (0,0)", G1, 170, 74, 1, esFOOT, 146);
    TEXTOUT_P(" = sens / (1 - spec)", G1, 170, 87, 1, esFOOT, 146);
    // v1.7c: es_EqTxt -- " = 0.005/0.900 < 0.01", never "= < 0.01"
    TEXTOUT_P(es_EqTxt(" = " + STRING(ROUND(rcSe, 3), 2, 3) + "/" + STRING(ROUND(rcFx, 3), 2, 3), es_FmtNum(rcLRp, 2)), G1, 170, 100, 1, esFOOT, 146);
    TEXTOUT_P("LR- = slope from (1,1)", G1, 170, 118, 1, esMAG, 146);
    TEXTOUT_P(" = (1 - sens) / spec", G1, 170, 131, 1, esMAG, 146);
    // v1.7c: es_EqTxt, as above
    TEXTOUT_P(es_EqTxt(" = " + STRING(ROUND(1 - rcSe, 3), 2, 3) + "/" + STRING(ROUND(rcSp, 3), 2, 3), es_FmtNum(rcLRn, 3)), G1, 170, 144, 1, esMAG, 146);
    TEXTOUT_P("Dotted line = chance: LR+ = LR- = 1.", G1, 6, 172, 1, esGRAY, 308);
    TEXTOUT_P("Youden J = sens + spec - 1 = " + STRING(ROUND(rcSe + rcSp - 1, 3), 2, 3) + ":", G1, 6, 185, 1, #000000h, 308);
    TEXTOUT_P("the dot's height above the dotted line.", G1, 6, 198, 1, #000000h, 308);
    es_DrawFooter(1);
    RETURN 1;
END;

// v1.7, module 3 page 6: a value 0..1 as a pixel column of the bars
// (x 110 = 0 %, 306 = 100 %), and one interval drawn as a 2-px bar
// with end caps.
es_XPx(xxV)
BEGIN
  RETURN 110 + IP(MAX(0, MIN(1, xxV)) * 196 + 0.5);
END;

es_XBar(xbLo, xbHi, xbY, xbCol)
BEGIN
  LOCAL xbA, xbB;
  xbA := es_XPx(xbLo); xbB := es_XPx(xbHi);
  LINE_P(G1, xbA, xbY, xbB, xbY, xbCol);
  LINE_P(G1, xbA, xbY + 1, xbB, xbY + 1, xbCol);
  LINE_P(G1, xbA, xbY - 2, xbA, xbY + 3, xbCol);
  LINE_P(G1, xbB, xbY - 2, xbB, xbY + 3, xbCol);
END;

// The CAS line for row xcR of page 6 (an event: the page opening or a
// tap on the row), once per row per table.  Only a count of 0 or n has
// a closed form: then the tail is a single term and
//   k = 0:  upper = 1 - (alpha/2)^(1/n) = 1-(1/40)^(1/n) at 95 %
//   k = n:  lower =     (alpha/2)^(1/n) =   (1/40)^(1/n)
// The CAS writes it exactly (and simplifies n = 1 and 2: 39/40, and
// 1-sqrt(1/40) in desktop Giac, which the Prime writes with the root
// sign, 1-√(1/40)).  simplify() is NOT used: in Giac 1.9 it turns many
// of these roots into "Root of ..." or an error.
es_XCas(xcR, xcK, xcN)
BEGIN
  LOCAL xcS;
  IF esXCK(xcR) <> 0 THEN RETURN 0; END;
  IF xcK == 0 THEN
    xcS := "1-(1/40)^(1/" + es_IStr(xcN) + ")";
  ELSE
    xcS := "(1/40)^(1/" + es_IStr(xcN) + ")";
  END;
  esXCT(xcR) := es_CasShow(es_Cas(xcS));
  esXCK(xcR) := 2;
  IF DIM(esXCT(xcR)) > 0 THEN esXCK(xcR) := 1; END;
  RETURN 1;
END;

// PAGE 6 of module 3 (v1.7): EXACT vs WILSON.  Each proportion is a
// row with three bars on one 0-100 % axis: Wilson (black), exact
// Clopper-Pearson (orange) and mid-P (green), and the estimate as a
// red tick through them.  The exact bar is always the longest: it is
// built so that its coverage never falls below 95 % for any true
// value.  A tap on a row (or Up/Down) puts its numbers in the panel;
// a count of 0 or n adds the CAS's closed form of the exact end.
es_ExactPage(xpA, xpB, xpC, xpD)
BEGIN
  LOCAL epI, epY, epK, epN, epNm, epW;
  LOCAL epS, epX, epR, epV, epT, epL;
  epNm := {"Sens", "Spec", "PPV", "NPV"};
  epL := {"Sensitivity a/(a+c)", "Specificity d/(b+d)", "PPV a/(a+b)", "NPV d/(c+d)"};
  epK := {xpA, xpD, xpA, xpD};
  epN := {xpA + xpC, xpB + xpD, xpA + xpB, xpC + xpD};
  // mid-P: once per table, on the first visit (two BINOMIAL_CDFs a pass)
  IF esBinOK == 1 AND esXMP(1) < 0 THEN
    FOR epI FROM 1 TO 4 DO
      epV := {0, 1};
      IF epN(epI) > 0 THEN epV := es_CPInt(epK(epI), epN(epI), 0.025, 1); END;
      esXMP(2 * epI - 1) := epV(1); esXMP(2 * epI) := epV(2);
    END;
  END;
  // the CAS line for the picked row, when its count is 0 or n
  epR := esXSel;
  IF esBinOK == 1 AND epN(epR) > 0 THEN
    IF epK(epR) == 0 OR epK(epR) == epN(epR) THEN es_XCas(epR, epK(epR), epN(epR)); END;
  END;
  es_DrawHeader("EXACT vs WILSON", "6/6");
  // ---- the axis: grid at 0, 25, 50, 75, 100 % ----
  FOR epI FROM 0 TO 4 DO
    epX := es_XPx(epI / 4);
    LINE_P(G1, epX, 26, epX, 154, esGRID);
    epS := STRING(25 * epI, 2, 0);
    IF epI == 4 THEN epS := "100%"; epX := epX - 14; END;    // kept on the screen
    TEXTOUT_P(epS, G1, epX - 6, 156, 1, esGRAY, 30);
  END;
  // ---- the four rows ----
  FOR epI FROM 1 TO 4 DO
    epY := 28 + 32 * (epI - 1);
    IF epI == epR THEN RECT_P(G1, 2, epY - 2, 317, epY + 25, esLORN, esLORN); END;
    es_AddHit(epI, 0, epY - 2, 319, epY + 25);
    TEXTOUT_P(epNm(epI) + " " + STRING(epK(epI), 2, 0) + "/" + STRING(epN(epI), 2, 0), G1, 4, epY, 1, #000000h, 104);
    IF epN(epI) > 0 THEN
      TEXTOUT_P(es_FmtPct(epK(epI) / epN(epI)), G1, 4, epY + 12, 1, esGRAY, 104);
      epW := es_WilsonCI(epK(epI), epN(epI));
      es_XBar(epW(1), epW(2), epY + 5, #000000h);
      IF esBinOK == 1 THEN
        es_XBar(esXCP(2 * epI - 1), esXCP(2 * epI), epY + 12, esORNG);
        es_XBar(esXMP(2 * epI - 1), esXMP(2 * epI), epY + 19, esGRN);
      END;
      epX := es_XPx(epK(epI) / epN(epI));
      LINE_P(G1, epX, epY + 1, epX, epY + 23, esRED);
    ELSE
      TEXTOUT_P("undefined (n = 0)", G1, 4, epY + 12, 1, esGRAY, 104);
    END;
  END;
  // ---- the panel: the picked row's numbers ----
  RECT_P(G1, 2, 167, 317, 216, esORNG, esLORN);
  IF epN(epR) > 0 THEN
    epS := epL(epR) + " = " + STRING(epK(epR), 2, 0) + "/" + STRING(epN(epR), 2, 0) + " = " + es_FmtPct(epK(epR) / epN(epR));
    TEXTOUT_P(epS, G1, 8, 170, 1, #000000h, 304);
    epW := es_WilsonCI(epK(epR), epN(epR));
    TEXTOUT_P("Wilson " + es_FmtCI(epW, 1), G1, 8, 182, 1, #000000h, 150);
    IF esBinOK == 1 THEN
      TEXTOUT_P("exact " + es_FmtCI({esXCP(2 * epR - 1), esXCP(2 * epR)}, 1), G1, 164, 182, 1, esORNG, 150);
      TEXTOUT_P("mid-P " + es_FmtCI({esXMP(2 * epR - 1), esXMP(2 * epR)}, 1), G1, 8, 194, 1, esGRN, 150);
      TEXTOUT_P("coverage >= 95 %", G1, 164, 194, 1, esORNG, 150);
      IF epK(epR) == 0 OR epK(epR) == epN(epR) THEN
        // the closed form: from the CAS when it answered, else the
        // formula in words with the PPL's decimal
        IF epK(epR) == 0 THEN epT := "exact upper = "; epV := esXCP(2 * epR); ELSE epT := "exact lower = "; epV := esXCP(2 * epR - 1); END;
        IF esXCK(epR) == 1 AND DIM(esXCT(epR)) <= 22 THEN
          TEXTOUT_P(epT + esXCT(epR), G1, 8, 206, 1, #000000h, 232);
          TEXTOUT_P("exact (CAS)", G1, 244, 206, 1, esFOOT, 72);
        ELSE
          IF epK(epR) == 0 THEN epS := "1 - (" + CHAR(945) + "/2)^(1/n)"; ELSE epS := "(" + CHAR(945) + "/2)^(1/n)"; END;
          TEXTOUT_P(epT + epS + " = " + STRING(ROUND(epV, 4), 2, 4), G1, 8, 206, 1, #000000h, 232);
          TEXTOUT_P("decimal", G1, 244, 206, 1, esFOOT, 72);
        END;
      ELSE
        TEXTOUT_P("bars: black Wilson, orange exact, green mid-P", G1, 8, 206, 1, esGRAY, 304);
      END;
    ELSE
      TEXTOUT_P("exact, mid-P: unavailable (BINOMIAL check)", G1, 8, 194, 1, esGRAY, 304);
    END;
  ELSE
    TEXTOUT_P(epL(epR) + ": undefined, its n is 0", G1, 8, 170, 1, #000000h, 304);
  END;
  es_Foot("tap a row = its numbers, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
  RETURN 1;
END;

es_Module_SensSpec()
BEGIN
    LOCAL a, b, c, d;
    LOCAL r1, r2, c1, c2, N;
    LOCAL sens, spec, ppv, npv, prev, acc;
    LOCAL LRpos, LRneg;
    LOCAL oddsR, RR;
    LOCAL kappa, youden, pe, po;
    LOCAL ea, eb, ec, ed;
    LOCAL chisq, fisher_p;
    LOCAL page, action;
    LOCAL inputOK, sOK, xcp, sOK1, sOK2;

    // v1.5: the touch grid replaces v1.4's INPUT form (Values or +
    // still opens it).  Rows = the test result, columns = the truth:
    //              Disease+   Disease-
    //   Test+       a = TP     b = FP
    //   Test-       c = FN     d = TN
    esMod := 3;
    esCel := esM3;
    inputOK := es_Grid("DIAGNOSTIC: COUNTS", {"Test+", "Test-"}, {"Disease+", "Disease-"});
    esM3 := esCel;
    IF inputOK == 0 THEN RETURN; END;
    a := esCel(1); b := esCel(2); c := esCel(3); d := esCel(4);

    // the grid never lets a negative or an empty table through; the
    // v1.4 checks stay as a guard
    IF a < 0 OR b < 0 OR c < 0 OR d < 0 THEN
        MSGBOX("Error: Values must be ≥ 0");
        RETURN;
    END;

    N := a + b + c + d;
    IF N == 0 THEN
        MSGBOX("Error: Table cannot be empty");
        RETURN;
    END;

    EP_table := {a, b, c, d};
    EP_N := N;

    // v1.4: every EP_* result this module sets only inside a guard
    // is reset here first (after the input is accepted, so a
    // cancelled form keeps the previous results, as EP_table does).
    // Before, a zero margin or a zero cell left the previous run's
    // value in place, and the pages below read EP_sens_CI, EP_OR_CI,
    // EP_chisq and EP_chisq_p straight from the globals -- so a stale
    // number could reach the screen.
    EP_sens := 0; EP_spec := 0; EP_ppv := 0; EP_npv := 0;
    EP_sens_CI := {0, 0}; EP_spec_CI := {0, 0};
    EP_ppv_CI := {0, 0}; EP_npv_CI := {0, 0};
    EP_LRpos := 999; EP_LRneg := 999;
    EP_OR := 999; EP_OR_CI := {0, 0}; EP_RR := 999;
    EP_chisq := 0; EP_chisq_p := 1;

    r1 := a + b;
    r2 := c + d;
    c1 := a + c;
    c2 := b + d;
    // v1.4: p1 := r1*r2*c1*c2 was computed here and never used

    IF c1 > 0 THEN
        sens := a / c1;
        EP_sens := sens;
        EP_sens_CI := es_WilsonCI(a, c1);
    ELSE
        sens := 0;
    END;

    IF c2 > 0 THEN
        spec := d / c2;
        EP_spec := spec;
        EP_spec_CI := es_WilsonCI(d, c2);
    ELSE
        spec := 0;
    END;

    IF r1 > 0 THEN
        ppv := a / r1;
        EP_ppv := ppv;
        EP_ppv_CI := es_WilsonCI(a, r1);
    ELSE
        ppv := 0;
    END;

    IF r2 > 0 THEN
        npv := d / r2;
        EP_npv := npv;
        EP_npv_CI := es_WilsonCI(d, r2);
    ELSE
        npv := 0;
    END;

    prev := c1 / N;
    acc := (a + d) / N;
    EP_acc := acc;

    // v1.7: the exact (Clopper-Pearson) CIs of the four proportions,
    // once per table (page 6 shows them beside Wilson's and mid-P's).
    // esXCP keeps the 8 ends in a flat list: sens, spec, PPV, NPV.
    EP_sens_CP := {0, 0}; EP_spec_CP := {0, 0};
    EP_ppv_CP := {0, 0}; EP_npv_CP := {0, 0};
    esXCP := {-1, -1, -1, -1, -1, -1, -1, -1};
    esXMP := {-1, -1, -1, -1, -1, -1, -1, -1};     // mid-P: on page 6's first visit
    esXSel := 1; esXCT := {"", "", "", ""}; esXCK := {0, 0, 0, 0};
    IF esBinOK == 1 THEN
        IF c1 > 0 THEN xcp := es_CPInt(a, c1, 0.025, 0); EP_sens_CP := xcp; esXCP(1) := xcp(1); esXCP(2) := xcp(2); END;
        IF c2 > 0 THEN xcp := es_CPInt(d, c2, 0.025, 0); EP_spec_CP := xcp; esXCP(3) := xcp(1); esXCP(4) := xcp(2); END;
        IF r1 > 0 THEN xcp := es_CPInt(a, r1, 0.025, 0); EP_ppv_CP := xcp; esXCP(5) := xcp(1); esXCP(6) := xcp(2); END;
        IF r2 > 0 THEN xcp := es_CPInt(d, r2, 0.025, 0); EP_npv_CP := xcp; esXCP(7) := xcp(1); esXCP(8) := xcp(2); END;
    END;

    IF spec < 1 THEN
        LRpos := sens / (1 - spec);
        EP_LRpos := LRpos;
    ELSE
        LRpos := 999;
    END;

    IF spec > 0 THEN
        LRneg := (1 - sens) / spec;
        EP_LRneg := LRneg;
    ELSE
        LRneg := 999;
    END;

    youden := sens + spec - 1;
    EP_youden := youden;

    // v1.7d (E3): sensitivity is a/(a+c) over the people WITH the
    // disease, specificity d/(b+d) over those without.  An empty column
    // leaves one of them undefined -- not 0 %, which v1.7c printed (the
    // 0 above is only a placeholder).  sOK1 = 1 when there is anyone
    // with the disease, sOK2 = 1 when there is anyone without.  LR+ =
    // sens/(1-spec), LR- = (1-sens)/spec and Youden's J = sens + spec - 1
    // need BOTH, so they take the 999 sentinel ("undef") when either
    // column is empty, on the pages and in the EP_* results alike.
    sOK1 := 0; IF c1 > 0 THEN sOK1 := 1; END;
    sOK2 := 0; IF c2 > 0 THEN sOK2 := 1; END;
    IF sOK1 == 0 OR sOK2 == 0 THEN
        LRpos := 999; LRneg := 999; youden := 999;
        EP_LRpos := 999; EP_LRneg := 999; EP_youden := 999;
    END;

    pe := (r1/N)*(c1/N) + (r2/N)*(c2/N);
    po := (a + d) / N;
    IF pe < 1 THEN
        kappa := (po - pe) / (1 - pe);
    ELSE
        kappa := 1;
    END;
    EP_kappa := kappa;

    IF b > 0 AND c > 0 THEN
        oddsR := (a * d) / (b * c);
        EP_OR := oddsR;
        EP_OR_CI := es_CalcORCI(a, b, c, d);
    ELSE
        oddsR := 999;
    END;

    IF r1 > 0 AND r2 > 0 AND c > 0 THEN
        RR := (a/r1) / (c/r2);
        EP_RR := RR;
    ELSE
        RR := 999;
    END;

    ea := r1*c1/N; eb := r1*c2/N; ec := r2*c1/N; ed := r2*c2/N;
    IF ea > 0 AND eb > 0 AND ec > 0 AND ed > 0 THEN
        chisq := (a-ea)^2/ea + (b-eb)^2/eb + (c-ec)^2/ec + (d-ed)^2/ed;
        EP_chisq := chisq;
        // v1.7c: the upper tail with 1 df, 2 NORMALD_CDF(-sqrt(x)) (never exactly 0)
        EP_chisq_p := 2 * NORMALD_CDF(0, 1, -(chisq^0.5));
    END;

    fisher_p := es_FisherExact(a, b, c, d);
    EP_fisher_p := fisher_p;

    // v1.5: five pages.  Page 4 (PPV and NPV against prevalence) runs
    // its own touch loop and hands back what the page loop should do;
    // page 5 is the ROC square.  Both need a sensitivity AND a
    // specificity, so both columns must hold someone (sOK).
    sOK := 0;
    IF c1 > 0 AND c2 > 0 THEN sOK := 1; END;
    esPv := prev;                       // page 4 opens at the sample's prevalence
    page := 1;
    REPEAT
        CASE
            IF page == 1 THEN
                // v1.7d (E3): which columns hold anyone, for "undef"
                es_SS_Page1(sens, spec, ppv, npv, LRpos, LRneg, acc, prev, sOK1, sOK2);
            END;
            IF page == 2 THEN
                es_SS_Page2(oddsR, RR, kappa, youden, po, pe);
            END;
            IF page == 3 THEN
                es_SS_Page3(a, b, c, d, r1, r2, c1, c2, N);
            END;
            IF page == 5 THEN
                es_ROCPage(sens, spec, LRpos, LRneg, sOK);
            END;
            // v1.7: exact against Wilson
            IF page == 6 THEN
                es_ExactPage(a, b, c, d);
            END;
        END;
        IF page == 4 THEN
            action := es_PrevPage(sens, spec, prev, sOK);
        ELSE
            action := es_WaitKey();
        END;
        IF action == 0 THEN BREAK; END;
        // v1.7: page 6 keeps a tap on a row and Up/Down for itself (the
        // row whose numbers the panel shows); everything else turns pages
        IF page == 6 AND action >= 3 AND action <= 5 THEN
            IF action == 3 THEN esXSel := MAX(1, MIN(4, esHit)); END;
            IF action == 4 THEN
                esXSel := esXSel - 1;
                IF esXSel < 1 THEN esXSel := 4; END;
            END;
            IF action == 5 THEN
                esXSel := esXSel + 1;
                IF esXSel > 4 THEN esXSel := 1; END;
            END;
        ELSE
            page := es_TurnPage(page, action, 6);   // taps turn pages too; v1.7: six pages
        END;
    UNTIL 0;
END;

es_SS_Page1(sens, spec, ppv, npv, LRpos, LRneg, acc, prev, sOK1, sOK2)
BEGIN
    LOCAL y, s1U, s1S;
    // v1.7d (E3): sOK1 = 0 when no one has the disease (a + c = 0), so
    // sensitivity is undefined; sOK2 = 0 when everyone has it (b + d =
    // 0), so specificity is.  s1U says why a ratio that needs both is
    // undefined (at most one column can be empty: the table is not).
    s1U := "";
    IF sOK2 == 0 THEN s1U := "undef (no Disease-)"; END;
    IF sOK1 == 0 THEN s1U := "undef (no Disease+)"; END;
    es_DrawHeader("DIAGNOSTIC ACCURACY", "1/6");
    y := 30;
    TEXTOUT_P("─── Primary Measures ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Sensitivity:", G1, 10, y, 2, RGB(0,0,0));
    // v1.7d (E3): "undef (no Disease+)", not "0.0%"; its CI is blank then
    // (EP_sens_CI stays {0, 0}, which es_FmtCI prints as nothing)
    s1S := es_FmtPct(sens);
    IF sOK1 == 0 THEN s1S := "undef (no Disease+)"; END;
    TEXTOUT_P(s1S, G1, 120, y, 2, RGB(0,140,60));
    TEXTOUT_P(es_FmtCI(EP_sens_CI, 1), G1, 180, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Specificity:", G1, 10, y, 2, RGB(0,0,0));
    // v1.7d (E3): the same for specificity, with no Disease- column
    s1S := es_FmtPct(spec);
    IF sOK2 == 0 THEN s1S := "undef (no Disease-)"; END;
    TEXTOUT_P(s1S, G1, 120, y, 2, RGB(0,140,60));
    TEXTOUT_P(es_FmtCI(EP_spec_CI, 1), G1, 180, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("PPV:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(ppv), G1, 120, y, 2, RGB(0,140,60));
    TEXTOUT_P(es_FmtCI(EP_ppv_CI, 1), G1, 180, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("NPV:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(npv), G1, 120, y, 2, RGB(0,140,60));
    TEXTOUT_P(es_FmtCI(EP_npv_CI, 1), G1, 180, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("─── Likelihood Ratios ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("LR+ (positive):", G1, 10, y, 2, RGB(0,0,0));
    // v1.7d (E3): an LR needs both sensitivity and specificity; with a
    // column empty it reads why ("undef (no Disease+)"), not 0.00
    s1S := es_FmtNum(LRpos, 2);
    IF sOK1 == 0 OR sOK2 == 0 THEN s1S := s1U; END;
    TEXTOUT_P(s1S, G1, 150, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("LR- (negative):", G1, 10, y, 2, RGB(0,0,0));
    s1S := es_FmtNum(LRneg, 3);
    IF sOK1 == 0 OR sOK2 == 0 THEN s1S := s1U; END;
    TEXTOUT_P(s1S, G1, 150, y, 2, RGB(0,140,60));
    y := y + 18;
    TEXTOUT_P("─── Other ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Accuracy:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(acc), G1, 100, y, 2, RGB(0,0,0));
    TEXTOUT_P("Prevalence:", G1, 160, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(prev), G1, 250, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("FPR (α):", G1, 10, y, 2, RGB(0,0,0));
    // v1.7d (E3): FPR = 1 - spec and FNR = 1 - sens are undefined with
    // their column empty (v1.7c printed 100.0%); "undef" fits the slot
    s1S := es_FmtPct(1-spec);
    IF sOK2 == 0 THEN s1S := "undef"; END;
    TEXTOUT_P(s1S, G1, 100, y, 2, RGB(255,0,0));
    TEXTOUT_P("FNR (β):", G1, 160, y, 2, RGB(0,0,0));
    s1S := es_FmtPct(1-sens);
    IF sOK1 == 0 THEN s1S := "undef"; END;
    TEXTOUT_P(s1S, G1, 250, y, 2, RGB(255,0,0));
    // v1.7: where the exact intervals are
    y := y + 16;
    TEXTOUT_P("CIs above: Wilson.  Exact and mid-P: page 6.", G1, 10, y, 1, esORNG, 300);
    es_DrawFooter(1);
END;

es_SS_Page2(oddsR, RR, kappa, youden, po, pe)
BEGIN
    LOCAL y, kappaInterp;
    es_DrawHeader("AGREEMENT & RISK", "2/6");
    y := 32;
    TEXTOUT_P("─── Agreement Measures ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("Cohen's Kappa:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(kappa, 3), G1, 150, y, 2, RGB(0,140,60));
    IF kappa < 0 THEN kappaInterp := "Poor";
    ELSE IF kappa < 0.2 THEN kappaInterp := "Slight";
    ELSE IF kappa < 0.4 THEN kappaInterp := "Fair";
    ELSE IF kappa < 0.6 THEN kappaInterp := "Moderate";
    ELSE IF kappa < 0.8 THEN kappaInterp := "Substantial";
    ELSE kappaInterp := "Almost Perfect";
    END; END; END; END; END;
    TEXTOUT_P("(" + kappaInterp + ")", G1, 210, y, 1, RGB(90,90,90));
    y := y + 16;
    TEXTOUT_P("Observed agreement:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(po), G1, 180, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Expected agreement:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtPct(pe), G1, 180, y, 2, RGB(0,0,0));
    y := y + 18;
    TEXTOUT_P("Youden's J Index:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(youden, 3), G1, 180, y, 2, RGB(0,140,60));
    y := y + 12;
    TEXTOUT_P("(Sens + Spec - 1, range -1 to 1)", G1, 20, y, 1, RGB(90,90,90));
    y := y + 22;
    TEXTOUT_P("─── Risk Measures ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("Odds Ratio:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(oddsR, 2), G1, 150, y, 2, RGB(0,140,60));
    y := y + 14;
    TEXTOUT_P("95% CI: " + es_FmtCI(EP_OR_CI, 0), G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("Relative Risk:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(RR, 2), G1, 150, y, 2, RGB(0,140,60));
    es_DrawFooter(1);
END;

es_SS_Page3(a, b, c, d, r1, r2, c1, c2, N)
BEGIN
    LOCAL y, x1, x2, x3;
    es_DrawHeader("TABLE SUMMARY", "3/6");
    x1 := 100; x2 := 170; x3 := 240;
    y := 32;
    TEXTOUT_P("─── Observed Values ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 14;
    TEXTOUT_P("Disease+", G1, x1, y, 1, RGB(0,60,200));
    TEXTOUT_P("Disease-", G1, x2, y, 1, RGB(0,60,200));
    TEXTOUT_P("Total", G1, x3, y, 1, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Test+", G1, 10, y, 2, RGB(0,60,200));
    TEXTOUT_P(STRING(a,1,0), G1, x1+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(b,1,0), G1, x2+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(r1,1,0), G1, x3+10, y, 2, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Test-", G1, 10, y, 2, RGB(0,60,200));
    TEXTOUT_P(STRING(c,1,0), G1, x1+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(d,1,0), G1, x2+15, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(r2,1,0), G1, x3+10, y, 2, RGB(90,90,90));
    y := y + 14;
    TEXTOUT_P("Total", G1, 10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(c1,1,0), G1, x1+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(c2,1,0), G1, x2+10, y, 2, RGB(90,90,90));
    TEXTOUT_P(STRING(N,1,0), G1, x3+10, y, 2, RGB(0,60,200));
    y := y + 22;
    TEXTOUT_P("─── Chi-Square & Fisher's ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Pearson χ²:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(EP_chisq, 3), G1, 120, y, 2, RGB(0,0,0));
    // v1.7c: the compact "p=" gives way to "p<0.0001" below the floor (never "p=< 0.0001")
    TEXTOUT_P(IFTE(EP_chisq_p < 0.0001, "p<0.0001", "p=" + es_FmtNum(EP_chisq_p, 4)), G1, 200, y, 2,
              IFTE(EP_chisq_p < 0.05, RGB(255,0,0), RGB(0,0,0)));
    y := y + 16;
    IF EP_fisher_p >= 0 THEN
        TEXTOUT_P("Fisher's p:", G1, 10, y, 2, RGB(0,0,0));
        TEXTOUT_P(es_FmtNum(EP_fisher_p, 5), G1, 120, y, 2,
                  IFTE(EP_fisher_p < 0.05, RGB(255,0,0), RGB(0,0,0)));
    ELSE
        TEXTOUT_P("N > 2000: Use χ² approximation", G1, 10, y, 2, RGB(90,90,90));
    END;
    y := y + 20;
    TEXTOUT_P("─── Cell Labels ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 14;
    TEXTOUT_P("a=TP  b=FP  c=FN  d=TN", G1, 10, y, 1, RGB(90,90,90));
    es_DrawFooter(1);
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 4: INCIDENCE RATES (Person-Time Analysis)
// ═══════════════════════════════════════════════════════════════════════════

es_Module_IncidenceRate()
BEGIN
    LOCAL cexp, u, pte, ptu;
    LOCAL IRe, IRu, IRt, IRR, IRD;
    LOCAL APe, APt, ERR;
    LOCAL WTe, WTu, WTt;
    LOCAL page, action;
    LOCAL inputOK;

    cexp := 0; u := 0; pte := 0; ptu := 0;

    inputOK := INPUT(
        {cexp, u, pte, ptu},
        "Incidence Rate Analysis",
        {"Cases (exposed):", "Cases (unexposed):", "Person-Time (exp):", "Person-Time (unexp):"},
        {"Number of cases among exposed",
         "Number of cases among unexposed",
         "Total person-time for exposed group",
         "Total person-time for unexposed group"},
        {0, 0, 0, 0}
    );

    IF inputOK == 0 THEN RETURN; END;

    IF cexp < 0 OR u < 0 OR pte <= 0 OR ptu <= 0 THEN
        MSGBOX("Error: Cases ≥ 0, Person-time > 0");
        RETURN;
    END;

    // v1.7d (E9): cases are counts.  v1.7c took 2.5 cases as typed and
    // printed "2.5 cases / 1200 person-time"; the entry grid has always
    // rounded a fractional count and said so.  This form now does the
    // same (person-time may keep its decimals).
    IF FP(cexp) <> 0 OR FP(u) <> 0 THEN
        MSGBOX("Cases are whole numbers: the values were rounded.");
        cexp := ROUND(cexp, 0);
        u := ROUND(u, 0);
    END;

    IRe := cexp / pte;
    IRu := u / ptu;
    IRt := (cexp + u) / (pte + ptu);

    EP_IR_exp := IRe;
    EP_IR_unexp := IRu;

    IF IRu > 0 THEN
        IRR := IRe / IRu;
        EP_IRR := IRR;
    ELSE
        IRR := 999;
        EP_IRR := 999;       // v1.4: was left from the previous run
    END;

    IRD := IRe - IRu;
    EP_IRD := IRD;

    // v1.4: keep the 999 sentinel so the display prints "undef"
    // (it used to print 998 and 99800%).
    IF IRR == 999 THEN ERR := 999; ELSE ERR := IRR - 1; END;

    IF IRe > 0 THEN
        APe := (IRe - IRu) / IRe * 100;
    ELSE
        APe := 0;
    END;

    IF IRt > 0 THEN
        APt := (IRt - IRu) / IRt * 100;
    ELSE
        APt := 0;
    END;

    IF IRe > 0 THEN WTe := 1/IRe; ELSE WTe := 999; END;
    IF IRu > 0 THEN WTu := 1/IRu; ELSE WTu := 999; END;
    IF IRt > 0 THEN WTt := 1/IRt; ELSE WTt := 999; END;

    page := 1;
    REPEAT
        CASE
            IF page == 1 THEN
                es_IR_Page1(cexp, u, pte, ptu, IRe, IRu, IRt);
            END;
            IF page == 2 THEN
                es_IR_Page2(IRR, IRD, ERR, APe, APt);
            END;
            IF page == 3 THEN
                es_IR_Page3(WTe, WTu, WTt, IRD);
            END;
        END;
        action := es_WaitKey();
        IF action == 0 THEN BREAK; END;
        page := es_TurnPage(page, action, 3);   // v1.5: taps turn pages too
    UNTIL 0;
END;

es_IR_Page1(cexp, u, pte, ptu, IRe, IRu, IRt)
BEGIN
    LOCAL y;
    es_DrawHeader("INCIDENCE RATES", "1/3");
    y := 32;
    TEXTOUT_P("─── Input Data ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 16;
    TEXTOUT_P("Exposed:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(cexp,1,0) + " cases / " + STRING(pte,1,0) + " person-time", G1, 100, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Unexposed:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(STRING(u,1,0) + " cases / " + STRING(ptu,1,0) + " person-time", G1, 100, y, 2, RGB(0,0,0));
    y := y + 22;
    TEXTOUT_P("─── Incidence Rates ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("IR (exposed):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(IRe, 5), G1, 150, y, 2, RGB(0,140,60));
    y := y + 14;
    // v1.7c: es_EqV -- "< 0.01 per 1,000 person-time", never "= < 0.01"
    TEXTOUT_P(es_EqV(es_FmtNum(IRe*1000, 2)) + " per 1,000 person-time", G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("IR (unexposed):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(IRu, 5), G1, 150, y, 2, RGB(0,140,60));
    y := y + 14;
    // v1.7c: es_EqV, as above
    TEXTOUT_P(es_EqV(es_FmtNum(IRu*1000, 2)) + " per 1,000 person-time", G1, 20, y, 1, RGB(90,90,90));
    y := y + 18;
    TEXTOUT_P("IR (total):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(IRt, 5), G1, 150, y, 2, RGB(0,0,0));
    y := y + 14;
    // v1.7c: es_EqV, as above
    TEXTOUT_P(es_EqV(es_FmtNum(IRt*1000, 2)) + " per 1,000 person-time", G1, 20, y, 1, RGB(90,90,90));
    es_DrawFooter(1);
END;

es_IR_Page2(IRR, IRD, ERR, APe, APt)
BEGIN
    LOCAL y;
    es_DrawHeader("RATE COMPARISONS", "2/3");
    y := 32;
    TEXTOUT_P("─── Rate Ratio (Relative Rate) ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    // v1.7c: es_EqLb -- no "=" in the label when the value is "< 0.001"
    TEXTOUT_P(es_EqLb("IRR = IRe / IRu", es_FmtNum(IRR, 3)), G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(IRR, 3), G1, 180, y, 2, RGB(0,140,60));
    y := y + 22;
    TEXTOUT_P("─── Rate Difference ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    // v1.7c: es_EqLb, as above
    TEXTOUT_P(es_EqLb("IRD = IRe - IRu", es_FmtNum(IRD, 5)), G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(IRD, 5), G1, 180, y, 2, IFTE(IRD > 0, RGB(255,0,0), RGB(0,140,60)));
    y := y + 14;
    // v1.7c: es_EqV, as above
    TEXTOUT_P(es_EqV(es_FmtNum(IRD*1000, 3)) + " per 1,000 PT", G1, 20, y, 1, RGB(90,90,90));
    y := y + 22;
    TEXTOUT_P("─── Excess Relative Rate ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    // v1.7c: es_EqLb, as above
    TEXTOUT_P(es_EqLb("ERR = IRR - 1", es_FmtNum(ERR, 3)), G1, 10, y, 2, RGB(0,0,0));
    // v1.4: ERR carries the 999 sentinel when IRR is undefined
    IF ERR == 999 THEN
        TEXTOUT_P("undef (IRR undefined)", G1, 150, y, 2, RGB(0,0,0));
    ELSE
        TEXTOUT_P(es_FmtNum(ERR, 3) + " (" + es_FmtNum(ERR*100, 1) + "%)", G1, 150, y, 2, RGB(0,0,0));
    END;
    y := y + 22;
    TEXTOUT_P("─── Attributable Proportions ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("AP (exposed):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(APe, 1) + "%", G1, 180, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("AP (total/pop):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(APt, 1) + "%", G1, 180, y, 2, RGB(0,0,0));
    es_DrawFooter(1);
END;

es_IR_Page3(WTe, WTu, WTt, IRD)
BEGIN
    LOCAL y, NNT;
    es_DrawHeader("CLINICAL MEASURES", "3/3");
    y := 32;
    // v1.7d (E6): the unit is named once, in the heading, and each
    // number stands alone at x 200.  v1.7c wrote "1000000.001 time
    // units" from x 200 for 1 case in 1,000,000 person-time, and the
    // line ran off the screen (about 354 px).  The heading says "Mean"
    // (the same thing as "Average") so that it fits too: 39 characters,
    // about 306 px at the checker's widest estimate (7.6 px).
    TEXTOUT_P("─── Mean Waiting Times (time units) ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("Waiting time (exposed):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(WTe, 2), G1, 200, y, 2, RGB(0,0,0));
    y := y + 16;
    TEXTOUT_P("Waiting time (unexposed):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(WTu, 2), G1, 200, y, 2, RGB(0,0,0));
    y := y + 16;
    TEXTOUT_P("Waiting time (overall):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P(es_FmtNum(WTt, 2), G1, 200, y, 2, RGB(0,0,0));
    y := y + 24;
    TEXTOUT_P("─── Person-Time NNT ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    IF IRD <> 0 THEN
        NNT := 1/ABS(IRD);
        IF IRD > 0 THEN
            // v1.7c: es_EqLb, as above (a rate above 10 per unit of person-time)
            TEXTOUT_P(es_EqLb("PT-NNH = 1/IRD", es_FmtNum(NNT, 1)), G1, 10, y, 2, RGB(0,0,0));
            TEXTOUT_P(es_FmtNum(NNT, 1), G1, 180, y, 2, RGB(255,0,0));
            y := y + 16;
            TEXTOUT_P("Person-time to observe 1 extra case", G1, 10, y, 1, RGB(90,90,90));
        ELSE
            // v1.7c: es_EqLb, as above
            TEXTOUT_P(es_EqLb("PT-NNT = 1/|IRD|", es_FmtNum(NNT, 1)), G1, 10, y, 2, RGB(0,0,0));
            TEXTOUT_P(es_FmtNum(NNT, 1), G1, 180, y, 2, RGB(0,140,60));
            y := y + 16;
            TEXTOUT_P("Person-time to prevent 1 case", G1, 10, y, 1, RGB(90,90,90));
        END;
    ELSE
        TEXTOUT_P("NNT undefined (IRD = 0)", G1, 10, y, 2, RGB(90,90,90));
    END;
    y := y + 24;
    TEXTOUT_P("─── Note ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 14;
    TEXTOUT_P("Person-time units depend on your data", G1, 10, y, 1, RGB(90,90,90));
    y := y + 10;
    TEXTOUT_P("(e.g., person-years, person-months)", G1, 10, y, 1, RGB(90,90,90));
    es_DrawFooter(1);
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 5: STRATIFIED ANALYSIS (Mantel-Haenszel Methods)
// v1.6: one engine for all three designs.  The two strata are typed
// in the touch grid (es_Grid), stratum 1 then stratum 2, in the
// module-1 layout, and kept in esSC = {a1, b1, c1, d1, a2, b2, c2, d2}:
//   kind 1  case-control   a = Exp+/Case    b = Exp+/Control
//                          c = Exp-/Case    d = Exp-/Control
//   kind 2  cohort         a = Exp+/Case    b = Exp+/no case
//                          c = Exp-/Case    d = Exp-/no case
//   kind 3  rates          a = Exp+ cases   b = Exp+ person-time
//                          c = Exp- cases   d = Exp- person-time
// Each design gets three pages: the strata, the M-H summary with its
// CI and tests, and a forest plot where a tap drops a stratum.
// ═══════════════════════════════════════════════════════════════════════════

es_Module_Stratified()
BEGIN
    LOCAL choice;
    choice := 0;   // v1.2: CHOOSE leaves its index unchanged on cancel
    CHOOSE(choice, "Stratified Analysis",
        "Case-Control (OR_MH)",
        "Cohort Study (RR_MH)",
        "Incidence Rates (IRR_MH)",
        "Back to Main Menu");
    // v1.6: the three designs share es_Strat_Run
    IF choice >= 1 AND choice <= 3 THEN es_Strat_Run(choice); END;
END;

// The two grids, stratum 1 then stratum 2 (v1.6).  The Values soft
// key opens the v1.4 four-field form for the stratum being typed.
// Each design remembers its last two strata in esMS while the
// program runs.  Returns 1 when both strata are ready, 0 on Cancel
// (Cancel on stratum 2 also goes back to the menu; nothing is lost).
es_StratGrids(sgK)
BEGIN
    LOCAL sgOK, sgS, sgT, sgCols, sgJ, sgB;
    esMod := 4 + sgK;                    // 5, 6 or 7: the grid's labels and form
    sgT := {"CASE-CONTROL", "COHORT", "RATES"};
    sgCols := {"Case", "Control"};
    IF sgK == 2 THEN sgCols := {"Outcome+", "Outcome-"}; END;
    IF sgK == 3 THEN sgCols := {"Cases", "Time (PT)"}; END;
    FOR sgS FROM 1 TO 2 DO
        esStr := sgS;
        sgB := (sgK - 1) * 8 + (sgS - 1) * 4;
        esCel := {esMS(sgB + 1), esMS(sgB + 2), esMS(sgB + 3), esMS(sgB + 4)};
        sgOK := es_Grid("STRATUM " + STRING(sgS, 2, 0) + " OF 2: " + sgT(sgK), {"Exposed+", "Exposed-"}, sgCols);
        FOR sgJ FROM 1 TO 4 DO
            esMS(sgB + sgJ) := esCel(sgJ);
            esSC((sgS - 1) * 4 + sgJ) := esCel(sgJ);
        END;
        IF sgOK == 0 THEN RETURN 0; END;
    END;
    RETURN 1;
END;

// One stratum's own estimate (v1.6): {estimate, CI low, CI high,
// M-H weight, flag}.  flag 0 = fine, 1 = 0.5 added to every cell (a
// zero cell; the odds ratio only, as es_CalcORCI does), -1 = no
// log-scale CI (the estimate is 999 = undefined, or 0).
//   OR   ad/bc,             Woolf CI (es_CalcORCI), weight bc/n
//   RR   [a/(a+b)]/[c/(c+d)], log CI (es_CalcRRCI), weight c(a+b)/n
//   IRR  (a/b)/(c/d),       CI exp(ln IRR -/+ 1.96 sqrt(1/a + 1/c)),
//                           weight c b/(b + d)
// The weights are the ones the M-H estimate gives each stratum; they
// size the forest plot's squares.
es_StratEst(seK, xsA, xsB, xsC, xsD)
BEGIN
    LOCAL seN, seE, seCI, seW, seF, seSE;
    seF := 0; seE := 999; seCI := {0, 0}; seW := 0;
    IF seK == 1 THEN
        seN := xsA + xsB + xsC + xsD;
        IF seN > 0 THEN seW := xsB * xsC / seN; END;
        IF xsA + xsB == 0 OR xsC + xsD == 0 OR xsA + xsC == 0 OR xsB + xsD == 0 THEN
            RETURN {999, 0, 0, seW, -1};          // an empty row or column
        END;
        IF xsA * xsB * xsC * xsD == 0 THEN
            seF := 1;
            seE := (xsA + 0.5) * (xsD + 0.5) / ((xsB + 0.5) * (xsC + 0.5));
        ELSE
            seE := xsA * xsD / (xsB * xsC);
        END;
        seCI := es_CalcORCI(xsA, xsB, xsC, xsD);
    END;
    IF seK == 2 THEN
        seN := xsA + xsB + xsC + xsD;
        IF seN > 0 THEN seW := xsC * (xsA + xsB) / seN; END;
        IF xsA + xsB == 0 OR xsC + xsD == 0 OR xsC == 0 THEN RETURN {999, 0, 0, seW, -1}; END;
        IF xsA == 0 THEN RETURN {0, 0, 0, seW, -1}; END;
        seE := (xsA / (xsA + xsB)) / (xsC / (xsC + xsD));
        seCI := es_CalcRRCI(xsA, xsA + xsB, xsC, xsC + xsD);
    END;
    IF seK == 3 THEN
        seN := xsB + xsD;
        IF seN > 0 THEN seW := xsC * xsB / seN; END;
        IF xsB <= 0 OR xsD <= 0 OR xsC == 0 THEN RETURN {999, 0, 0, seW, -1}; END;
        IF xsA == 0 THEN RETURN {0, 0, 0, seW, -1}; END;
        seE := (xsA / xsB) / (xsC / xsD);
        seSE := (1 / xsA + 1 / xsC)^0.5;
        seCI := {EXP(LN(seE) - 1.96 * seSE), EXP(LN(seE) + 1.96 * seSE)};
    END;
    RETURN {seE, seCI(1), seCI(2), seW, seF};
END;

// The expected a under a common odds ratio bfPsi, with the stratum's
// margins fixed (Breslow-Day).  It is the root of
//   (1 - psi) A^2 + (n - r1 - c1 + psi (r1 + c1)) A - psi r1 c1 = 0
// that lies between MAX(0, r1 + c1 - n) and MIN(r1, c1), taken in
// the form that does not lose digits when psi is near 1.
es_BDFit(bfA, bfB, bfC, bfD, bfPsi)
BEGIN
    LOCAL bfN, bfR1, bfC1, bfQa, bfQb, bfQc;
    LOCAL bfSq, bfQ, bfX1, bfLo, bfHi;
    bfN := bfA + bfB + bfC + bfD;
    bfR1 := bfA + bfB; bfC1 := bfA + bfC;
    bfLo := MAX(0, bfR1 + bfC1 - bfN);
    bfHi := MIN(bfR1, bfC1);
    bfQa := 1 - bfPsi;
    bfQb := bfN - bfR1 - bfC1 + bfPsi * (bfR1 + bfC1);
    bfQc := -bfPsi * bfR1 * bfC1;
    IF ABS(bfQa) < 0.000000000001 THEN RETURN -bfQc / bfQb; END;
    bfSq := MAX(0, bfQb^2 - 4 * bfQa * bfQc)^0.5;
    IF bfQb >= 0 THEN bfQ := -0.5 * (bfQb + bfSq); ELSE bfQ := -0.5 * (bfQb - bfSq); END;
    bfX1 := bfQ / bfQa;
    IF bfX1 >= bfLo - 0.000000001 AND bfX1 <= bfHi + 0.000000001 THEN RETURN bfX1; END;
    RETURN bfQc / bfQ;
END;

// THE MANTEL-HAENSZEL ENGINE (v1.6).  mcS = the eight cells (esSC),
// mcM = {drop1, drop2}: a stratum with 1 is left out (the forest
// plot's tap).  Returns a list of 13:
//   1-3   M-H estimate, CI low, CI high   (999 = undefined; CI 0, 0)
//   4-6   crude estimate (the included strata added together) and CI
//   7-8   M-H chi-square (1 df) and p, no continuity correction
//   9-10  the same with the correction            (-1 = undefined)
//   11-12 Breslow-Day chi-square and p, OR only   (-1 = undefined)
//   13    how many strata were included
// Formulas, all checked against statsmodels in verify_EpiStats.py:
//   OR_MH  = sum(ad/n) / sum(bc/n); var ln OR_MH by Robins, Breslow
//            and Greenland (1986): with P = (a+d)/n, Q = (b+c)/n,
//            R = ad/n, S = bc/n,
//            sum(PR)/(2 R+^2) + sum(PS+QR)/(2 R+ S+) + sum(QS)/(2 S+^2)
//   RR_MH  = sum(a n0/n) / sum(c n1/n); var ln RR_MH by Greenland and
//            Robins (1985): sum(m1 n1 n0/n^2 - ac/n) / [sum(a n0/n) sum(c n1/n)]
//   IRR_MH = sum(a T0/T) / sum(c T1/T); var ln IRR_MH (Greenland and
//            Robins 1985): sum(m1 T1 T0/T^2) / [sum(a T0/T) sum(c T1/T)]
//   CI     = exp(ln est -/+ 1.959964 SE)
//   M-H chi-square = (|sum(a - E a)| - 0.5)^2 / sum(Var a), with
//            E a = (a+b)(a+c)/n, Var a = (a+b)(c+d)(a+c)(b+d)/(n^2 (n-1));
//            the 0.5 only when |sum(a - E a)| >= 0.5 (R's rule;
//            statsmodels test_null_odds reports the uncorrected one by
//            default).  The same test of "no association" serves the
//            cohort design.  Not computed for rates.
//   Breslow-Day = sum (a - A)^2 / Var A over the strata, A the
//            expected a under OR_MH (es_BDFit), 1/Var A = 1/A +
//            1/(r1-A) + 1/(c1-A) + 1/(n-r1-c1+A); k - 1 df; no Tarone
//            adjustment (statsmodels test_equal_odds' default).
es_MHCalc(mcK, mcS, mcM)
BEGIN
    LOCAL mcI, mcA, mcB, mcC, mcD, mcN;
    LOCAL mcR, mcSS, mcPR, mcX, mcQS, mcNI;
    LOCAL mcDl, mcVs, mcE, mcLo, mcHi, mcV;
    LOCAL mcCr, mcBD, mcPB, mcX2, mcX2c, mcP;
    LOCAL mcPc, mcAs, mcBs, mcCs, mcDs, mcSe;
    LOCAL mcP1, mcP2, mcFA, mcVa, mcNB, mcR1;
    LOCAL mcC1;
    mcR := 0; mcSS := 0; mcPR := 0; mcX := 0; mcQS := 0; mcNI := 0;
    mcDl := 0; mcVs := 0; mcAs := 0; mcBs := 0; mcCs := 0; mcDs := 0;
    FOR mcI FROM 1 TO 2 DO
        IF mcM(mcI) == 0 THEN
            mcA := mcS(4 * mcI - 3); mcB := mcS(4 * mcI - 2);
            mcC := mcS(4 * mcI - 1); mcD := mcS(4 * mcI);
            mcAs := mcAs + mcA; mcBs := mcBs + mcB;
            mcCs := mcCs + mcC; mcDs := mcDs + mcD;
            mcNI := mcNI + 1;
            IF mcK == 3 THEN
                mcN := mcB + mcD;                   // person-time T = T1 + T0
                IF mcN > 0 THEN
                    mcR := mcR + mcA * mcD / mcN;
                    mcSS := mcSS + mcC * mcB / mcN;
                    mcPR := mcPR + (mcA + mcC) * mcB * mcD / mcN^2;
                END;
            ELSE
                mcN := mcA + mcB + mcC + mcD;
                IF mcN > 0 THEN
                    IF mcK == 1 THEN
                        mcP1 := (mcA + mcD) / mcN; mcP2 := (mcB + mcC) / mcN;
                        mcR := mcR + mcA * mcD / mcN;
                        mcSS := mcSS + mcB * mcC / mcN;
                        mcPR := mcPR + mcP1 * mcA * mcD / mcN;
                        mcX := mcX + mcP1 * mcB * mcC / mcN + mcP2 * mcA * mcD / mcN;
                        mcQS := mcQS + mcP2 * mcB * mcC / mcN;
                    ELSE
                        mcR := mcR + mcA * (mcC + mcD) / mcN;
                        mcSS := mcSS + mcC * (mcA + mcB) / mcN;
                        mcPR := mcPR + (mcA + mcC) * (mcA + mcB) * (mcC + mcD) / mcN^2 - mcA * mcC / mcN;
                    END;
                END;
                IF mcN >= 2 THEN                     // the M-H chi-square sums
                    mcDl := mcDl + mcA - (mcA + mcB) * (mcA + mcC) / mcN;
                    mcVs := mcVs + (mcA + mcB) * (mcC + mcD) * (mcA + mcC) * (mcB + mcD) / (mcN^2 * (mcN - 1));
                END;
            END;
        END;
    END;
    // ---- the M-H estimate and its CI ----
    mcE := 999; mcLo := 0; mcHi := 0;
    IF mcSS > 0 THEN
        mcE := mcR / mcSS;
        IF mcR > 0 THEN
            IF mcK == 1 THEN
                mcV := mcPR / (2 * mcR^2) + mcX / (2 * mcR * mcSS) + mcQS / (2 * mcSS^2);
            ELSE
                mcV := mcPR / (mcR * mcSS);
            END;
            mcSe := MAX(0, mcV)^0.5;
            mcLo := EXP(LN(mcE) - 1.959964 * mcSe);
            mcHi := EXP(LN(mcE) + 1.959964 * mcSe);
        END;
    END;
    // ---- the crude estimate: the included strata added together ----
    mcCr := es_StratEst(mcK, mcAs, mcBs, mcCs, mcDs);
    IF mcCr(5) == -1 THEN mcCr := {mcCr(1), 0, 0, 0, -1}; END;
    // ---- the M-H chi-square ----
    mcX2 := -1; mcP := -1; mcX2c := -1; mcPc := -1;
    IF mcK < 3 AND mcVs > 0 THEN
        mcX2 := mcDl^2 / mcVs;
        // v1.7d (E8): 1 df, so the upper tail is the lower normal tail
        // 2 NORMALD_CDF(0, 1, -sqrt(x)), as for McNemar (es_McNemar):
        // 1 - CHISQUARE_CDF is exactly 0 on the Prime above about 52,
        // and EP_MH_P read 0 where this keeps its digits
        mcP := 2 * NORMALD_CDF(0, 1, -(mcX2^0.5));
        mcX2c := mcX2;
        IF ABS(mcDl) >= 0.5 THEN mcX2c := (ABS(mcDl) - 0.5)^2 / mcVs; END;
        mcPc := 2 * NORMALD_CDF(0, 1, -(mcX2c^0.5));
    END;
    // ---- Breslow-Day: are the strata's odds ratios alike? ----
    mcBD := -1; mcPB := -1;
    IF mcK == 1 AND mcE <> 999 AND mcE > 0 THEN
        mcBD := 0; mcNB := 0;
        FOR mcI FROM 1 TO 2 DO
            IF mcM(mcI) == 0 THEN
                mcA := mcS(4 * mcI - 3); mcB := mcS(4 * mcI - 2);
                mcC := mcS(4 * mcI - 1); mcD := mcS(4 * mcI);
                mcN := mcA + mcB + mcC + mcD;
                mcR1 := mcA + mcB; mcC1 := mcA + mcC;
                // a stratum with an empty row or column carries no information
                IF mcR1 > 0 AND mcC1 > 0 AND mcR1 < mcN AND mcC1 < mcN THEN
                    mcFA := es_BDFit(mcA, mcB, mcC, mcD, mcE);
                    IF MIN(MIN(mcFA, mcR1 - mcFA), MIN(mcC1 - mcFA, mcN - mcR1 - mcC1 + mcFA)) > 0.000000000001 THEN
                        mcVa := 1 / (1 / mcFA + 1 / (mcR1 - mcFA) + 1 / (mcC1 - mcFA) + 1 / (mcN - mcR1 - mcC1 + mcFA));
                        mcBD := mcBD + (mcA - mcFA)^2 / mcVa;
                        mcNB := mcNB + 1;
                    END;
                END;
            END;
        END;
        IF mcNB >= 2 THEN
            // v1.7d (E8): Breslow-Day has K - 1 df for K strata in the
            // pool.  This module takes two strata and skips Breslow-Day
            // when one is dropped, so here K = mcNB = 2 and df = 1: the
            // upper tail is 2 NORMALD_CDF(0, 1, -sqrt(x)), kept to its
            // last digit (1 - CHISQUARE_CDF(1, x) is exactly 0 above
            // about 52 on the Prime, and EP_BD_P read 0).  A future third
            // stratum would need df = mcNB - 1 = 2: the tail is then
            // EXP(-x/2); with more, chi-square's own upper tail.
            mcPB := 2 * NORMALD_CDF(0, 1, -(mcBD^0.5));
        ELSE
            mcBD := -1;
        END;
    END;
    RETURN {mcE, mcLo, mcHi, mcCr(1), mcCr(2), mcCr(3), mcX2, mcP, mcX2c, mcPc, mcBD, mcPB, mcNI};
END;

// An estimate and its CI as text: "3.681  (1.84-7.37)", "undef".
es_SumTxt(stV, stLo, stHi)
BEGIN
    LOCAL stC;
    IF stV == 999 THEN RETURN "undef"; END;
    stC := es_FmtCI({stLo, stHi}, 0);
    IF DIM(stC) == 0 THEN RETURN es_FmtNum(stV, 3) + " (no CI)"; END;
    RETURN es_FmtNum(stV, 3) + "  " + stC;
END;

// One stratum's estimate as text, from es_StratEst's list.
es_EstTxt(etL)
BEGIN
    LOCAL etS;
    IF etL(5) == -1 THEN
        IF etL(1) == 999 THEN RETURN "undef"; END;
        RETURN es_FmtNum(etL(1), 3) + " (no CI: a zero count)";
    END;
    etS := es_FmtNum(etL(1), 3) + "  " + es_FmtCI({etL(2), etL(3)}, 0);
    IF etL(5) == 1 THEN etS := etS + " +0.5"; END;
    RETURN etS;
END;

// PAGE 1: the two strata as typed, each with its own estimate and
// 95 % CI.
es_StratTables(tbK, tbPg)
BEGIN
    LOCAL tbS, tbY, tbA, tbB, tbC, tbD;
    LOCAL tbE, tbH, tbNm, tbT, tbX;
    tbNm := {"OR", "RR", "IRR"};
    tbH := {"STRATIFIED TABLES", "STRATUM RISKS", "STRATUM RATES"};
    es_DrawHeader(tbH(tbK), tbPg);
    FOR tbS FROM 1 TO 2 DO
        tbA := esSC(4 * tbS - 3); tbB := esSC(4 * tbS - 2);
        tbC := esSC(4 * tbS - 1); tbD := esSC(4 * tbS);
        tbY := 30 + 82 * (tbS - 1);
        IF tbK == 3 THEN
            tbT := "PT = " + es_Cnt(tbB + tbD);
        ELSE
            tbT := "n = " + STRING(tbA + tbB + tbC + tbD, 2, 0);
        END;
        TEXTOUT_P("─── Stratum " + STRING(tbS, 2, 0) + " (" + tbT + ") ───", G1, 10, tbY, 2, esBLUE, 306);
        // column heads, then the two rows
        IF tbK == 1 THEN tbX := {"Case", "Control", ""}; END;
        IF tbK == 2 THEN tbX := {"Case", "No case", "risk"}; END;
        IF tbK == 3 THEN tbX := {"Cases", "PT", "rate"}; END;
        TEXTOUT_P(tbX(1), G1, 110, tbY + 16, 1, esGRAY, 60);
        TEXTOUT_P(tbX(2), G1, 175, tbY + 16, 1, esGRAY, 70);
        TEXTOUT_P(tbX(3), G1, 250, tbY + 16, 1, esGRAY, 60);
        TEXTOUT_P("Exp+", G1, 20, tbY + 28, 2, esBLUE, 80);
        TEXTOUT_P(es_Cnt(tbA), G1, 110, tbY + 28, 2, #000000h, 64);
        TEXTOUT_P(es_Cnt(tbB), G1, 175, tbY + 28, 2, #000000h, 72);
        TEXTOUT_P("Exp-", G1, 20, tbY + 42, 2, esBLUE, 80);
        TEXTOUT_P(es_Cnt(tbC), G1, 110, tbY + 42, 2, #000000h, 64);
        TEXTOUT_P(es_Cnt(tbD), G1, 175, tbY + 42, 2, #000000h, 72);
        IF tbK == 2 THEN
            IF tbA + tbB > 0 THEN TEXTOUT_P(es_FmtPct(tbA / (tbA + tbB)), G1, 250, tbY + 28, 2, esGRAY, 66); END;
            IF tbC + tbD > 0 THEN TEXTOUT_P(es_FmtPct(tbC / (tbC + tbD)), G1, 250, tbY + 42, 2, esGRAY, 66); END;
        END;
        IF tbK == 3 THEN
            TEXTOUT_P(es_RateTx(tbA, tbB), G1, 250, tbY + 28, 2, esGRAY, 66);
            TEXTOUT_P(es_RateTx(tbC, tbD), G1, 250, tbY + 42, 2, esGRAY, 66);
        END;
        tbE := es_StratEst(tbK, tbA, tbB, tbC, tbD);
        // v1.7c: es_EqTxt -- "OR₁ < 0.001  (...)", never "= <"
        TEXTOUT_P(es_EqTxt(tbNm(tbK) + CHAR(8320 + tbS), es_EstTxt(tbE)), G1, 20, tbY + 58, 2, esGRN, 296);
    END;
    IF tbK == 3 THEN
        TEXTOUT_P("Total PT = " + es_Cnt(esSC(2) + esSC(4) + esSC(6) + esSC(8)), G1, 10, 196, 2, #000000h, 300);
    ELSE
        TEXTOUT_P("Total N = " + STRING(esSC(1) + esSC(2) + esSC(3) + esSC(4) + esSC(5) + esSC(6) + esSC(7) + esSC(8), 2, 0), G1, 10, 196, 2, #000000h, 300);
    END;
    es_DrawFooter(1);
END;

// The reading of the summary (v1.6).  The red "Confounding likely
// present" verdicts of v1.5 are gone: the 10 % change-in-estimate is
// a rule of thumb, so it is stated as one, next to the Breslow-Day p
// that says whether one pooled estimate is a fair summary at all.
// The difference is a share of the M-H (adjusted) estimate.
es_RuleTxt(rtK, rtM, rtY)
BEGIN
    LOCAL rtPc, rtNm, rtL, rtE1, rtE2;
    rtL := {"OR", "RR", "IRR"};
    rtNm := "" + rtL(rtK);
    IF rtM(1) == 999 OR rtM(4) == 999 OR rtM(1) <= 0 OR rtM(4) <= 0 THEN
        rtY := es_ALine("Crude or adjusted " + rtNm + " is undefined (a zero),", rtY, esGRAY);
        rtY := es_ALine("so the 10 % rule cannot be applied.", rtY, esGRAY);
    ELSE
        rtPc := 100 * ABS(rtM(4) - rtM(1)) / rtM(1);
        rtY := es_ALine("Crude and M-H differ by " + STRING(ROUND(rtPc, 0), 2, 0) + " % (of the M-H).", rtY, #000000h);
        IF rtPc >= 10 THEN
            rtY := es_ALine("By the usual 10 % rule of thumb: adjust.", rtY, #000000h);
        ELSE
            rtY := es_ALine("Under the usual 10 % rule of thumb: no need.", rtY, #000000h);
        END;
    END;
    IF rtK == 1 THEN
        IF rtM(12) >= 0 THEN
            IF rtM(12) >= 0.05 THEN
                rtY := es_ALine("Strata ORs homogeneous (Breslow-Day p = " + STRING(rtM(12), 2, 2) + "):", rtY, #000000h);
                rtY := es_ALine("one adjusted OR is a fair summary.", rtY, #000000h);
            ELSE
                rtY := es_ALine("Strata ORs differ (Breslow-Day " + es_PTxt(rtM(12), 3) + "):", rtY, esORNG);
                rtY := es_ALine("report each stratum, not one pooled OR.", rtY, esORNG);
            END;
        ELSE
            rtY := es_ALine("No Breslow-Day test (an empty row or column", rtY, esGRAY);
            rtY := es_ALine("in a stratum): compare the strata on page 3.", rtY, esGRAY);
        END;
    ELSE
        // no homogeneity test here for RR or IRR: the v1.4 heuristic
        // (one stratum more than half again the other), said as one
        rtE1 := es_StratEst(rtK, esSC(1), esSC(2), esSC(3), esSC(4));
        rtE2 := es_StratEst(rtK, esSC(5), esSC(6), esSC(7), esSC(8));
        IF rtE1(5) <> -1 AND rtE2(5) <> -1 THEN
            IF MAX(rtE1(1), rtE2(1)) / MIN(rtE1(1), rtE2(1)) > 1.5 THEN
                rtY := es_ALine(rtNm + CHAR(8321) + " and " + rtNm + CHAR(8322) + " differ by more than half:", rtY, esORNG);
                rtY := es_ALine("look for effect modification (page 3).", rtY, esORNG);
            ELSE
                rtY := es_ALine(rtNm + CHAR(8321) + " and " + rtNm + CHAR(8322) + " are close: one adjusted", rtY, #000000h);
                rtY := es_ALine(rtNm + " is a fair summary (no formal test here).", rtY, #000000h);
            END;
        ELSE
            rtY := es_ALine("A stratum " + rtNm + " has no CI (a zero count):", rtY, esGRAY);
            rtY := es_ALine("compare the strata on page 3.", rtY, esGRAY);
        END;
    END;
    RETURN rtY;
END;

// PAGE 2: crude against M-H, the CI, the tests, and their reading.
es_MHSummary(smK, smM, smPg)
BEGIN
    LOCAL smY, smNm, smS, smL;
    smL := {"OR", "RR", "IRR"};
    smNm := "" + smL(smK);
    es_DrawHeader("MANTEL-HAENSZEL " + smNm, smPg);
    smY := 30;
    TEXTOUT_P("─── Crude vs adjusted ───", G1, 10, smY, 2, esBLUE, 306);
    smY := smY + 16;
    // v1.7c: es_EqTxt on the crude and the M-H estimate
    TEXTOUT_P(es_EqTxt("Crude " + smNm, es_SumTxt(smM(4), smM(5), smM(6))), G1, 10, smY, 2, #000000h, 306);
    smY := smY + 14;
    TEXTOUT_P(es_EqTxt("M-H " + smNm, es_SumTxt(smM(1), smM(2), smM(3))), G1, 10, smY, 2, esGRN, 306);
    smY := smY + 14;
    IF smK == 1 THEN
        smS := "95% CI: Robins-Breslow-Greenland variance";
    ELSE
        smS := "95% CI: Greenland-Robins (1985) variance";
    END;
    TEXTOUT_P(smS, G1, 20, smY, 1, esGRAY, 296);
    smY := smY + 16;
    IF smK < 3 THEN
        TEXTOUT_P("─── Tests ───", G1, 10, smY, 2, esBLUE, 306);
        smY := smY + 16;
        IF smM(7) >= 0 THEN
            smY := es_ALine("M-H χ² (1 df) = " + STRING(ROUND(smM(7), 3), 2, 3) + ", " + es_PTxt(smM(8), 4), smY, #000000h);
            smY := es_ALine("  with continuity correction: " + STRING(ROUND(smM(9), 3), 2, 3) + ", " + es_PTxt(smM(10), 4), smY, esGRAY);
        ELSE
            smY := es_ALine("M-H χ²: undefined (no variance)", smY, esGRAY);
        END;
        IF smK == 1 THEN
            IF smM(12) >= 0 THEN
                smY := es_ALine("Breslow-Day χ² (1 df) = " + STRING(ROUND(smM(11), 3), 2, 3) + ", " + es_PTxt(smM(12), 3), smY, #000000h);
            ELSE
                smY := es_ALine("Breslow-Day: undefined (an empty margin)", smY, esGRAY);
            END;
        END;
        smY := smY + 4;
    END;
    TEXTOUT_P("─── Reading it ───", G1, 10, smY, 2, esBLUE, 306);
    smY := smY + 16;
    smY := es_RuleTxt(smK, smM, smY);
    es_DrawFooter(1);
END;

// A log10 value as a pixel column of the forest plot (x 100..306).
es_FX(fxL, fxLo, fxHi)
BEGIN
    RETURN MIN(306, MAX(100, 100 + IP((fxL - fxLo) / (fxHi - fxLo) * 206 + 0.5)));
END;

// Drop or restore stratum tgS in the forest plot's pool (v1.6).  One
// stratum must always stay in: a tap that would empty the pool sets
// esFMsg and changes nothing.
es_Toggle(tgS)
BEGIN
    IF tgS < 1 OR tgS > 2 THEN RETURN 0; END;
    IF esDrp(tgS) == 1 THEN
        esDrp(tgS) := 0; esFMsg := 0;
        RETURN 1;
    END;
    IF esDrp(3 - tgS) == 1 THEN esFMsg := 1; RETURN 0; END;
    esDrp(tgS) := 1; esFMsg := 0;
    RETURN 1;
END;

// PAGE 3: THE FOREST PLOT (v1.6), modelled on TrendTest's trDrawOR.
// Rows: stratum 1, stratum 2, the crude estimate, the M-H estimate,
// on a log10 axis with ticks at 1, 2 and 5 in each decade.  Each
// stratum is a blue square whose AREA follows its M-H weight, with
// its own 95 % CI (Woolf for OR) as a bar; the crude estimate is a
// small black square; the M-H estimate is a magenta diamond (LINE_P
// outline) whose tips are its CI; the dotted line is "no
// association" (1).  A tap on a stratum's row (hit boxes 1, 2), or
// Up/Down, drops it from the pool: the M-H estimate and the crude
// row are worked out again from the strata left (es_MHCalc with the
// mask esDrp), the dropped row turns grey.  With two strata, one
// left means the M-H estimate is that stratum's own -- the page says
// so.  The EP_* results keep the full analysis.
es_ForestDraw(fdK, fdPg)
BEGIN
    LOCAL fdL, fdNm, fdM, fdE1, fdE2, fdI;
    LOCAL fdRE, fdRL, fdRH, fdRF, fdLo, fdHi;
    LOCAL fdV, fdD, fdMu, fdTk, fdX, fdLx;
    LOCAL fdY, fdW1, fdW2, fdWm, fdHs, fdCol;
    LOCAL fdS, fdXl, fdXh, fdRow, fdW;
    fdL := {"OR", "RR", "IRR"};
    fdNm := "" + fdL(fdK);
    fdE1 := es_StratEst(fdK, esSC(1), esSC(2), esSC(3), esSC(4));
    fdE2 := es_StratEst(fdK, esSC(5), esSC(6), esSC(7), esSC(8));
    fdM := es_MHCalc(fdK, esSC, esDrp);
    // the four rows: estimate, CI low, CI high, -1 = cannot be drawn
    fdRE := {fdE1(1), fdE2(1), fdM(4), fdM(1)};
    fdRL := {fdE1(2), fdE2(2), fdM(5), fdM(2)};
    fdRH := {fdE1(3), fdE2(3), fdM(6), fdM(3)};
    fdRF := {fdE1(5), fdE2(5), 0, 0};
    FOR fdI FROM 1 TO 4 DO
        IF fdRE(fdI) == 999 OR fdRE(fdI) <= 0 OR fdRL(fdI) <= 0 OR fdRH(fdI) >= 999 THEN fdRF(fdI) := -1; END;
    END;
    // ---- the axis: every CI that can be drawn, and 1; at most
    //      10^-3 .. 10^3; at least 0.6 decades; 8 % to spare ----
    fdLo := 0; fdHi := 0;
    FOR fdI FROM 1 TO 4 DO
        IF fdRF(fdI) <> -1 THEN
            fdLo := MIN(fdLo, LOG(fdRL(fdI)));
            fdHi := MAX(fdHi, LOG(fdRH(fdI)));
        END;
    END;
    fdLo := MAX(fdLo, -3); fdHi := MIN(fdHi, 3);
    fdV := fdHi - fdLo;
    IF fdV < 0.6 THEN
        fdLo := fdLo - (0.6 - fdV) / 2; fdHi := fdHi + (0.6 - fdV) / 2; fdV := 0.6;
    END;
    fdLo := fdLo - 0.08 * fdV; fdHi := fdHi + 0.08 * fdV;
    es_DrawHeader("FOREST PLOT: " + fdNm, fdPg);
    // ---- ticks at 1, 2, 5 in each decade; a label only where there
    //      is room (24 px from the last one) ----
    fdMu := {1, 2, 5}; fdLx := -100;
    FOR fdD FROM FLOOR(fdLo) TO CEILING(fdHi) DO
        FOR fdI FROM 1 TO 3 DO
            fdTk := fdD + LOG(fdMu(fdI));
            IF fdTk >= fdLo AND fdTk <= fdHi THEN
                fdX := es_FX(fdTk, fdLo, fdHi);
                LINE_P(G1, fdX, 34, fdX, 158, esGRID);
                IF fdX - fdLx >= 24 THEN
                    fdV := fdMu(fdI) * 10^fdD;
                    fdW := MAX(0, -fdD);           // decimals: 0.5 has 1, 0.05 has 2
                    fdS := STRING(fdV, 2, fdW);
                    TEXTOUT_P(fdS, G1, fdX - 6, 162, 1, esGRAY, 36);
                    fdLx := fdX;
                END;
            END;
        END;
    END;
    LINE_P(G1, 100, 159, 306, 159, #000000h);
    LINE_P(G1, 4, 96, 315, 96, esGRID);              // strata above, summaries below
    fdX := es_FX(0, fdLo, fdHi);                      // log 1 = 0: no association
    FOR fdY FROM 34 TO 158 STEP 3 DO PIXON_P(G1, fdX, fdY, esGRAY); END;
    TEXTOUT_P(fdNm + ", log scale", G1, 4, 162, 1, esGRAY, 92);
    // ---- the rows ----
    fdW1 := fdE1(4); fdW2 := fdE2(4);
    fdWm := MAX(fdW1, fdW2);
    fdRow := {50, 78, 114, 142};
    FOR fdI FROM 1 TO 4 DO
        fdY := fdRow(fdI);
        // the label column
        IF fdI <= 2 THEN
            IF fdI == 1 THEN fdW := fdW1; ELSE fdW := fdW2; END;
            IF esDrp(fdI) == 1 THEN
                TEXTOUT_P("Stratum " + STRING(fdI, 2, 0), G1, 4, fdY - 10, 1, esGRAY, 92);
                TEXTOUT_P("dropped", G1, 4, fdY + 1, 1, esRED, 92);
            ELSE
                TEXTOUT_P("Stratum " + STRING(fdI, 2, 0), G1, 4, fdY - 10, 1, #000000h, 92);
                fdS := "w = 0";
                IF fdW1 + fdW2 > 0 THEN fdS := "w = " + STRING(ROUND(100 * fdW / (fdW1 + fdW2), 0), 2, 0) + " %"; END;
                TEXTOUT_P(fdS, G1, 4, fdY + 1, 1, esGRAY, 92);
            END;
        END;
        IF fdI == 3 THEN
            TEXTOUT_P("Crude", G1, 4, fdY - 10, 1, #000000h, 92);
            TEXTOUT_P("added up", G1, 4, fdY + 1, 1, esGRAY, 92);
        END;
        IF fdI == 4 THEN
            TEXTOUT_P("M-H", G1, 4, fdY - 10, 1, esMAG, 92);
            TEXTOUT_P("pooled", G1, 4, fdY + 1, 1, esMAG, 92);
        END;
        // the mark and its CI
        IF fdRF(fdI) == -1 THEN
            IF fdRE(fdI) == 999 THEN
                fdS := "undefined (a zero count)";
            ELSE
                fdS := fdNm + " = " + es_FmtNum(fdRE(fdI), 2) + ": no log-scale CI";
            END;
            TEXTOUT_P(fdS, G1, 110, fdY - 5, 1, esGRAY, 196);
        ELSE
            fdX := es_FX(LOG(fdRE(fdI)), fdLo, fdHi);
            fdXl := es_FX(LOG(fdRL(fdI)), fdLo, fdHi);
            fdXh := es_FX(LOG(fdRH(fdI)), fdLo, fdHi);
            IF fdI <= 2 THEN
                fdCol := esBLUE;
                IF esDrp(fdI) == 1 THEN fdCol := #B4B4B4h; END;
                LINE_P(G1, fdXl, fdY, fdXh, fdY, fdCol);
                LINE_P(G1, fdXl, fdY - 3, fdXl, fdY + 3, fdCol);
                LINE_P(G1, fdXh, fdY - 3, fdXh, fdY + 3, fdCol);
                // a square whose area follows the weight
                IF fdI == 1 THEN fdW := fdW1; ELSE fdW := fdW2; END;
                fdHs := 2;
                IF fdWm > 0 THEN fdHs := 2 + IP(5 * (fdW / fdWm)^0.5 + 0.5); END;
                IF esDrp(fdI) == 1 THEN
                    LINE_P(G1, fdX - fdHs, fdY - fdHs, fdX + fdHs, fdY - fdHs, fdCol);
                    LINE_P(G1, fdX + fdHs, fdY - fdHs, fdX + fdHs, fdY + fdHs, fdCol);
                    LINE_P(G1, fdX + fdHs, fdY + fdHs, fdX - fdHs, fdY + fdHs, fdCol);
                    LINE_P(G1, fdX - fdHs, fdY + fdHs, fdX - fdHs, fdY - fdHs, fdCol);
                ELSE
                    RECT_P(G1, fdX - fdHs, fdY - fdHs, fdX + fdHs, fdY + fdHs, fdCol, fdCol);
                END;
            END;
            IF fdI == 3 THEN
                LINE_P(G1, fdXl, fdY, fdXh, fdY, #000000h);
                LINE_P(G1, fdXl, fdY - 3, fdXl, fdY + 3, #000000h);
                LINE_P(G1, fdXh, fdY - 3, fdXh, fdY + 3, #000000h);
                RECT_P(G1, fdX - 3, fdY - 3, fdX + 3, fdY + 3, #000000h, #000000h);
            END;
            IF fdI == 4 THEN
                // the M-H diamond: tips at the CI, 7 px high at the estimate
                LINE_P(G1, fdXl, fdY, fdX, fdY - 7, esMAG);
                LINE_P(G1, fdX, fdY - 7, fdXh, fdY, esMAG);
                LINE_P(G1, fdXh, fdY, fdX, fdY + 7, esMAG);
                LINE_P(G1, fdX, fdY + 7, fdXl, fdY, esMAG);
                LINE_P(G1, fdX, fdY - 6, fdX, fdY + 6, esMAG);
            END;
        END;
    END;
    // ---- the readout ----
    // v1.7c: es_EqTxt on the M-H and the crude estimate
    fdS := es_EqTxt("M-H " + fdNm, es_SumTxt(fdM(1), fdM(2), fdM(3)));
    IF esDrp(1) + esDrp(2) == 0 THEN
        fdS := fdS + ", both";
    ELSE
        IF esDrp(1) == 1 THEN fdS := fdS + ", stratum 2 only"; ELSE fdS := fdS + ", stratum 1 only"; END;
    END;
    TEXTOUT_P(fdS, G1, 6, 170, 1, esMAG, 308);
    TEXTOUT_P(es_EqTxt("Crude " + fdNm, es_SumTxt(fdM(4), fdM(5), fdM(6))), G1, 6, 182, 1, #000000h, 308);
    CASE
        IF esFMsg == 1 THEN
            TEXTOUT_P("At least one stratum must stay in the pool.", G1, 6, 194, 1, esRED, 308);
        END;
        IF esDrp(1) + esDrp(2) > 0 THEN
            TEXTOUT_P("One stratum left: M-H = that stratum's " + fdNm + ".", G1, 6, 194, 1, #000000h, 308);
        END;
        IF fdK == 1 AND fdM(12) >= 0 THEN
            TEXTOUT_P("Breslow-Day " + es_PTxt(fdM(12), 2) + ": are the strata's ORs alike?", G1, 6, 194, 1, #000000h, 308);
        END;
        DEFAULT
            TEXTOUT_P("Square size = M-H weight, bar = 95% CI.", G1, 6, 194, 1, esGRAY, 308);
    END;
    IF esDrp(1) + esDrp(2) > 0 THEN
        TEXTOUT_P("Tap it again (or Up/Down) to put it back.", G1, 6, 206, 1, esGRAY, 308);
    ELSE
        TEXTOUT_P("Tap a stratum (Up = 1, Down = 2) to drop it.", G1, 6, 206, 1, esGRAY, 308);
    END;
    es_AddHit(1, 0, 34, 319, 63);
    es_AddHit(2, 0, 64, 319, 95);
    es_Foot("tap row = drop/restore, " + CHAR(8592) + "/" + CHAR(8594) + " = page, Esc = menu");
END;

// The stratified design srK (1 case-control, 2 cohort, 3 rates),
// v1.6: two grids, then three pages -- the strata, the M-H summary,
// the forest plot.  The EP_* results are set once from the full
// analysis (both strata), before any dropping on the forest plot.
es_Strat_Run(srK)
BEGIN
    LOCAL srM, srPg, srAct, srN1, srN2;
    IF es_StratGrids(srK) == 0 THEN RETURN 0; END;
    IF srK == 3 THEN
        srN1 := esSC(2) + esSC(4); srN2 := esSC(6) + esSC(8);
        IF srN1 <= 0 OR srN2 <= 0 THEN
            MSGBOX("Error: Person-time cannot be zero");
            RETURN 0;
        END;
    ELSE
        srN1 := esSC(1) + esSC(2) + esSC(3) + esSC(4);
        srN2 := esSC(5) + esSC(6) + esSC(7) + esSC(8);
        IF srN1 == 0 OR srN2 == 0 THEN
            MSGBOX("Error: Strata cannot be empty");
            RETURN 0;
        END;
    END;
    srM := es_MHCalc(srK, esSC, {0, 0});
    IF srK == 1 THEN
        EP_OR_MH := srM(1);
        EP_ORMH_CI := {srM(2), srM(3)};
        EP_MH_CHI := srM(7); EP_MH_P := srM(8);
        EP_BD_P := srM(12);
    END;
    IF srK == 2 THEN
        EP_RR_MH := srM(1);
        EP_RRMH_CI := {srM(2), srM(3)};
        EP_MH_CHI := srM(7); EP_MH_P := srM(8);
    END;
    IF srK == 3 THEN
        EP_IRR_MH := srM(1);
        EP_IRRMH_CI := {srM(2), srM(3)};
    END;
    esDrp := {0, 0}; esFMsg := 0;
    srPg := 1;
    REPEAT
        CASE
            IF srPg == 1 THEN es_StratTables(srK, "1/3"); END;
            IF srPg == 2 THEN es_MHSummary(srK, srM, "2/3"); END;
            DEFAULT es_ForestDraw(srK, "3/3");
        END;
        srAct := es_WaitKey();
        IF srAct == 0 THEN BREAK; END;
        IF srPg == 3 AND srAct >= 3 AND srAct <= 5 THEN
            // on the forest plot: a tap on a row, Up (stratum 1) or
            // Down (stratum 2) drops or restores that stratum
            IF srAct == 3 THEN es_Toggle(esHit); END;
            IF srAct == 4 THEN es_Toggle(1); END;
            IF srAct == 5 THEN es_Toggle(2); END;
        ELSE
            esFMsg := 0;
            srPg := es_TurnPage(srPg, srAct, 3);
        END;
    UNTIL 0;
    RETURN 1;
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 6: SAMPLE SIZE CALCULATOR
// ═══════════════════════════════════════════════════════════════════════════

es_Module_SampleSize()
BEGIN
    LOCAL choice;
    choice := 0;   // v1.2: CHOOSE leaves its index unchanged on cancel
    CHOOSE(choice, "Sample Size Calculator",
        "Two Proportions",
        "Two Means",
        "Single Proportion (est.)",
        "Single Mean (est.)",
        "Back to Main Menu");
    CASE
        IF choice == 1 THEN es_SS_TwoProportions(); END;
        IF choice == 2 THEN es_SS_TwoMeans(); END;
        IF choice == 3 THEN es_SS_OneProportion(); END;
        IF choice == 4 THEN es_SS_OneMean(); END;
    END;
END;

es_SS_TwoProportions()
BEGIN
    LOCAL p1, p2, alpha, power;
    LOCAL z_alpha, z_beta, pbar, n;
    LOCAL y;
    LOCAL inputOK;

    p1 := 0.5; p2 := 0.3; alpha := 0.05; power := 0.8;

    inputOK := INPUT(
        {p1, p2, alpha, power},
        "Sample Size: Two Proportions",
        {"Proportion 1:", "Proportion 2:", "Alpha (α):", "Power (1-β):"},
        {"Expected proportion in group 1",
         "Expected proportion in group 2",
         "Significance level (e.g. 0.05)",
         "Desired power (e.g. 0.80)"},
        {0.5, 0.3, 0.05, 0.8}
    );

    IF inputOK == 0 THEN RETURN; END;

    IF p1 <= 0 OR p1 >= 1 OR p2 <= 0 OR p2 >= 1 THEN
        MSGBOX("Error: Proportions must be between 0 and 1");
        RETURN;
    END;

    IF p1 == p2 THEN
        MSGBOX("Error: Proportions must differ");
        RETURN;
    END;

    // v1.7d (E1): alpha and power are probabilities strictly between 0
    // and 1.  v1.7c never checked them, so alpha = 0 or power = 1 sent
    // NORMALD_ICDF a p of exactly 1 (or 0, or 1.5), which has no finite
    // z; what the Prime does then has never been seen.  Refused first.
    IF alpha <= 0 OR alpha >= 1 THEN
        MSGBOX("α must be between 0 and 1 (0.05 is usual).");
        RETURN;
    END;
    IF power <= 0 OR power >= 1 THEN
        MSGBOX("Power must be between 0 and 1 (0.80 is usual).");
        RETURN;
    END;

    z_alpha := NORMALD_ICDF(0, 1, 1 - alpha/2);
    z_beta := NORMALD_ICDF(0, 1, power);
    pbar := (p1 + p2)/2;

    n := 2 * pbar * (1 - pbar) * (z_alpha + z_beta)^2 / (p1 - p2)^2;
    n := CEILING(n);

    es_DrawHeader("SAMPLE SIZE: 2 PROPORTIONS", "");
    y := 35;
    TEXTOUT_P("─── Parameters ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("p₁ = " + STRING(p1,1,0) + "   p₂ = " + STRING(p2,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("α = " + STRING(alpha,1,0) + "   Power = " + STRING(power,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    // v1.7c: es_EqTxt -- a power of 0.5001 reads "z_β < 0.001", never "= < 0.001"
    TEXTOUT_P(es_EqTxt("z_α/2", es_FmtNum(z_alpha, 3)) + es_EqTxt("   z_β", es_FmtNum(z_beta, 3)), G1, 10, y, 1, RGB(90,90,90));
    y := y + 24;
    TEXTOUT_P("─── Required Sample Size ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 20;
    TEXTOUT_P("Per group:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P("n = " + STRING(n,1,0), G1, 150, y, 3, RGB(0,140,60));
    y := y + 24;
    TEXTOUT_P("Total (both groups):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P("N = " + STRING(2*n,1,0), G1, 200, y, 3, RGB(0,140,60));
    y := y + 26;
    TEXTOUT_P("Effect size (difference):", G1, 10, y, 1, RGB(90,90,90));
    TEXTOUT_P(es_FmtPct(ABS(p1-p2)), G1, 200, y, 1, RGB(90,90,90));
    es_DrawFooter(0);    // single page: any key or tap goes back
    es_WaitAny();
END;

es_SS_TwoMeans()
BEGIN
    LOCAL delta, sigma, alpha, power;
    LOCAL z_alpha, z_beta, n, effectSize;
    LOCAL y;
    LOCAL inputOK;

    delta := 1; sigma := 2; alpha := 0.05; power := 0.8;

    inputOK := INPUT(
        {delta, sigma, alpha, power},
        "Sample Size: Two Means",
        {"Mean Difference (δ):", "Std Dev (σ):", "Alpha (α):", "Power (1-β):"},
        {"Expected difference in means",
         "Common standard deviation",
         "Significance level (e.g. 0.05)",
         "Desired power (e.g. 0.80)"},
        {1, 2, 0.05, 0.8}
    );

    IF inputOK == 0 THEN RETURN; END;

    IF sigma <= 0 THEN
        MSGBOX("Error: Std Dev must be > 0");
        RETURN;
    END;

    IF delta == 0 THEN
        MSGBOX("Error: Difference must be non-zero");
        RETURN;
    END;

    // v1.7d (E1): alpha and power strictly between 0 and 1, checked
    // before NORMALD_ICDF (see es_SS_TwoProportions)
    IF alpha <= 0 OR alpha >= 1 THEN
        MSGBOX("α must be between 0 and 1 (0.05 is usual).");
        RETURN;
    END;
    IF power <= 0 OR power >= 1 THEN
        MSGBOX("Power must be between 0 and 1 (0.80 is usual).");
        RETURN;
    END;

    z_alpha := NORMALD_ICDF(0, 1, 1 - alpha/2);
    z_beta := NORMALD_ICDF(0, 1, power);
    effectSize := ABS(delta)/sigma;

    n := 2 * sigma^2 * (z_alpha + z_beta)^2 / delta^2;
    n := CEILING(n);

    es_DrawHeader("SAMPLE SIZE: 2 MEANS", "");
    y := 35;
    TEXTOUT_P("─── Parameters ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("δ = " + STRING(delta,1,0) + "   σ = " + STRING(sigma,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("α = " + STRING(alpha,1,0) + "   Power = " + STRING(power,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    // v1.7c: es_EqTxt -- an effect below 0.001 reads "(Cohen's d) < 0.001"
    TEXTOUT_P(es_EqTxt("Effect size (Cohen's d)", es_FmtNum(effectSize, 3)), G1, 10, y, 1, RGB(90,90,90));
    y := y + 24;
    TEXTOUT_P("─── Required Sample Size ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 20;
    TEXTOUT_P("Per group:", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P("n = " + STRING(n,1,0), G1, 150, y, 3, RGB(0,140,60));
    y := y + 24;
    TEXTOUT_P("Total (both groups):", G1, 10, y, 2, RGB(0,0,0));
    TEXTOUT_P("N = " + STRING(2*n,1,0), G1, 200, y, 3, RGB(0,140,60));
    es_DrawFooter(0);    // single page: any key or tap goes back
    es_WaitAny();
END;

es_SS_OneProportion()
BEGIN
    LOCAL p, moe, alpha;      // v1.4: the margin of error was `E`, the exponent marker / reserved e
    LOCAL z_alpha, n;
    LOCAL y;
    LOCAL inputOK;

    p := 0.5; moe := 0.05; alpha := 0.05;

    inputOK := INPUT(
        {p, moe, alpha},
        "Sample Size: Estimate Proportion",
        {"Expected p:", "Margin of Error (E):", "Alpha (α):"},
        {"Expected proportion (0.5 = most conservative)",
         "Desired margin of error (± half-width)",
         "Significance level (e.g. 0.05)"},
        {0.5, 0.05, 0.05}
    );

    IF inputOK == 0 THEN RETURN; END;

    IF p <= 0 OR p >= 1 OR moe <= 0 THEN
        MSGBOX("Error: 0<p<1 and E>0 required");
        RETURN;
    END;

    // v1.7d (E1): alpha strictly between 0 and 1, checked before
    // NORMALD_ICDF (see es_SS_TwoProportions)
    IF alpha <= 0 OR alpha >= 1 THEN
        MSGBOX("α must be between 0 and 1 (0.05 is usual).");
        RETURN;
    END;

    z_alpha := NORMALD_ICDF(0, 1, 1 - alpha/2);
    n := z_alpha^2 * p * (1-p) / moe^2;
    n := CEILING(n);

    es_DrawHeader("SAMPLE SIZE: 1 PROPORTION", "");
    y := 40;
    TEXTOUT_P("─── Parameters ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("p = " + STRING(p,1,0) + "   E = ±" + STRING(moe,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Confidence = " + es_FmtPct(1-alpha), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    // v1.7c: es_EqTxt, as above
    TEXTOUT_P(es_EqTxt("z_α/2", es_FmtNum(z_alpha, 4)), G1, 10, y, 1, RGB(90,90,90));
    y := y + 26;
    TEXTOUT_P("─── Required Sample Size ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 22;
    TEXTOUT_P("n = " + STRING(n,1,0), G1, 100, y, 4, RGB(0,140,60));
    y := y + 30;
    TEXTOUT_P("(Use p=0.5 for most conservative", G1, 10, y, 1, RGB(90,90,90));
    y := y + 10;
    TEXTOUT_P(" estimate if p is unknown)", G1, 10, y, 1, RGB(90,90,90));
    es_DrawFooter(0);    // single page: any key or tap goes back
    es_WaitAny();
END;

es_SS_OneMean()
BEGIN
    LOCAL sigma, moe, alpha;  // v1.4: the margin of error was `E`, the exponent marker / reserved e
    LOCAL z_alpha, n;
    LOCAL y;
    LOCAL inputOK;

    sigma := 10; moe := 2; alpha := 0.05;

    inputOK := INPUT(
        {sigma, moe, alpha},
        "Sample Size: Estimate Mean",
        {"Std Dev (σ):", "Margin of Error (E):", "Alpha (α):"},
        {"Population standard deviation",
         "Desired margin of error (± half-width)",
         "Significance level (e.g. 0.05)"},
        {10, 2, 0.05}
    );

    IF inputOK == 0 THEN RETURN; END;

    IF sigma <= 0 OR moe <= 0 THEN
        MSGBOX("Error: σ>0 and E>0 required");
        RETURN;
    END;

    // v1.7d (E1): alpha strictly between 0 and 1, checked before
    // NORMALD_ICDF (see es_SS_TwoProportions)
    IF alpha <= 0 OR alpha >= 1 THEN
        MSGBOX("α must be between 0 and 1 (0.05 is usual).");
        RETURN;
    END;

    z_alpha := NORMALD_ICDF(0, 1, 1 - alpha/2);
    n := (z_alpha * sigma / moe)^2;
    n := CEILING(n);

    es_DrawHeader("SAMPLE SIZE: 1 MEAN", "");
    y := 40;
    TEXTOUT_P("─── Parameters ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 18;
    TEXTOUT_P("σ = " + STRING(sigma,1,0) + "   E = ±" + STRING(moe,1,0), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    TEXTOUT_P("Confidence = " + es_FmtPct(1-alpha), G1, 10, y, 2, RGB(0,0,0));
    y := y + 14;
    // v1.7c: es_EqTxt, as above
    TEXTOUT_P(es_EqTxt("z_α/2", es_FmtNum(z_alpha, 4)), G1, 10, y, 1, RGB(90,90,90));
    y := y + 26;
    TEXTOUT_P("─── Required Sample Size ───", G1, 10, y, 2, RGB(0,60,200));
    y := y + 22;
    TEXTOUT_P("n = " + STRING(n,1,0), G1, 100, y, 4, RGB(0,140,60));
    es_DrawFooter(0);    // single page: any key or tap goes back
    es_WaitAny();
END;

// ═══════════════════════════════════════════════════════════════════════════
// MODULE 7: HELP & INFORMATION
// ═══════════════════════════════════════════════════════════════════════════

es_Module_Help()
BEGIN
    LOCAL choice;
    REPEAT
        choice := 0;   // v1.2: CHOOSE leaves its index unchanged on cancel
        // v1.6: fourteen items, the most the simple CHOOSE form takes
        // (Lessons Learned section 2).  v1.7: sixteen, so the menu now
        // uses the LIST form, CHOOSE(var, "title", {...}), which has no
        // such limit (ChiTable's and BayesTree's longer menus use it).
        // The Prime labels items 10 to 16 as A, B, C, D, E, F and G.
        CHOOSE(choice, "Help & Information", {
            "About EpiStats",
            "2×2 Table Guide",
            "Sensitivity/Specificity",
            "Risk & Odds Ratios",
            "Confidence Intervals",
            "Mantel-Haenszel",
            "Kappa Interpretation",
            "Variable Reference",
            "Entry Grid & Soft Keys",
            "Result Pages & Taps",
            "PPV vs Prevalence, ROC",
            "Readings & Fisher Bars",
            "Forest, Risk Bars, NNT",
            "Exact CIs, Exact OR",
            "Where the SE Comes From",
            "Back to Main Menu"});
        CASE
            IF choice == 1 THEN es_Help_About(); END;
            IF choice == 2 THEN es_Help_Table(); END;
            IF choice == 3 THEN es_Help_SensSpec(); END;
            IF choice == 4 THEN es_Help_RiskOdds(); END;
            IF choice == 5 THEN es_Help_CI(); END;
            IF choice == 6 THEN es_Help_MH(); END;
            IF choice == 7 THEN es_Help_Kappa(); END;
            IF choice == 8 THEN es_Help_Variables(); END;
            IF choice == 9 THEN es_Help_Grid(); END;
            IF choice == 10 THEN es_Help_Pages(); END;
            IF choice == 11 THEN es_Help_Prev(); END;
            IF choice == 12 THEN es_Help_Read(); END;
            IF choice == 13 THEN es_Help_Forest(); END;
            IF choice == 14 THEN es_Help_Exact(); END;      // v1.7: E
            IF choice == 15 THEN es_Help_SE(); END;         // v1.7: F
        END;
    UNTIL choice == 0 OR choice == 16;
END;

// v1.4: the eight help texts were MSGBOXes of 7 to 13 lines; MSGBOX
// shows about five, so the rest -- the Landis-Koch scale among them
// -- was never seen.  Each is now a drawn page (es_HelpPage, one blit
// from G1).  Every sentence of the v1.3 text is kept; the few glyphs
// the fleet has not verified on the device (a sigma with subscript i,
// an em dash, a kappa) are written out in ASCII.


es_Help_About()
BEGIN
    es_HelpPage("ABOUT EPISTATS",
        {"EpiStats v1.7d",
         "",
         "Comprehensive biostatistics and epidemiology",
         "calculator.",
         "",
         "Modules: 2×2 tables, risk analysis, diagnostic",
         "tests, incidence rates, stratified (Mantel-",
         "Haenszel) analysis, and sample size.",
         "From Home: EpiStats2x2(a,b,c,d) opens the 2×2",
         "results at once and returns Fisher's p.",
         "EP_* results.  Touch: Help 9-D; exact: E, F."});
END;


es_Help_Table()
BEGIN
    es_HelpPage("2×2 CONTINGENCY TABLE",
        {"            Outcome+    Outcome-",
         "Exp+            a             b",
         "Exp-            c             d",
         "",
         "χ² tests association; Fisher's exact is",
         "preferred when N is small or any expected",
         "cell < 5.",
         "",
         "φ (phi) measures strength of association."});
END;

es_Help_SensSpec()
BEGIN
    es_HelpPage("DIAGNOSTIC TEST MEASURES",
        {"Sensitivity = a/(a+c) = TP/(TP+FN)",
         "Specificity = d/(b+d) = TN/(TN+FP)",
         "PPV = a/(a+b)   NPV = d/(c+d)",
         "",
         "LR+ = Sens/(1-Spec)",
         "LR- = (1-Sens)/Spec",
         "Youden's J = Sens + Spec - 1",
         "",
         "Note: PPV and NPV depend on prevalence."});
END;

es_Help_RiskOdds()
BEGIN
    es_HelpPage("RISK & ODDS RATIOS",
        {"Risk Ratio (RR) = Re/Ru",
         "  Use for cohort studies.",
         "",
         "Odds Ratio (OR) = (a×d)/(b×c)",
         "  Use for case-control studies.",
         "",
         "RR = 1 or OR = 1: no association",
         "> 1: positive association",
         "< 1: protective effect",
         "",
         "OR approximates RR when disease is rare."});
END;

es_Help_CI()
BEGIN
    es_HelpPage("CONFIDENCE INTERVALS",
        {"Proportions: Wilson score interval (better",
         "coverage than Wald, esp. for small n).",
         "Exact (Clopper-Pearson) beside it: Help E.",
         "",
         "OR/RR: log method - compute CI on ln(OR),",
         "then exponentiate.  Its SE: Help F.",
         "",
         "If a 95% CI for OR/RR excludes 1, the",
         "association is significant at α = 0.05.",
         "For RD, the CI excludes 0 when significant."});
END;

es_Help_MH()
BEGIN
    es_HelpPage("MANTEL-HAENSZEL METHODS",
        {"Combine 2×2 tables across strata to control",
         "for a confounder: OR_MH = sum(ad/n)/sum(bc/n).",
         "95% CI: Robins-Breslow-Greenland for the OR,",
         "Greenland-Robins (1985) for RR and IRR.",
         "M-H χ² (1 df): is the common OR 1?",
         "Breslow-Day (k-1 df): are the strata's ORs",
         "alike?  Small p: report them separately.",
         "Rule of thumb, not a test: if crude and",
         "adjusted differ by 10 % or more, adjust.",
         "Page 3, forest plot: tap a stratum (or",
         "Up/Down) to drop it from the pool and watch",
         "the M-H estimate change; tap again = back."});
END;


es_Help_Kappa()
BEGIN
    es_HelpPage("COHEN'S KAPPA",
        {"kappa = (Po - Pe)/(1 - Pe)",
         "Measures agreement beyond chance.",
         "",
         "< 0.00      Poor",
         "0.00-0.20   Slight",
         "0.21-0.40   Fair",
         "0.41-0.60   Moderate",
         "0.61-0.80   Substantial",
         "0.81-1.00   Almost Perfect",
         "",
         "(Landis & Koch, 1977)"});
END;

es_Help_Variables()
BEGIN
    // v1.7d (E4): the blank line names the 999 sentinel, which EP_RD now
    // carries too (a row empty), as EP_RR, EP_OR, the LRs and EP_youden
    // do when they are undefined; the page keeps its 12 lines
    es_HelpPage("STORED RESULT VARIABLES",
        {"EP_chisq, EP_chisq_p, EP_fisher_p",
         "EP_OR, EP_OR_CI, EP_RR, EP_RR_CI",
         "EP_RD, EP_NNT, EP_AFe, EP_AFp",
         "EP_sens, EP_spec, EP_ppv, EP_npv",
         "EP_LRpos, EP_LRneg, EP_youden, EP_kappa",
         "EP_IR_exp, EP_IR_unexp, EP_IRR, EP_IRD",
         "EP_OR_MH, EP_RR_MH, EP_IRR_MH",
         "EP_ORMH_CI, EP_MH_CHI, EP_MH_P, EP_BD_P",
         "EP_RRMH_CI, EP_IRRMH_CI, EP_MCN_P",
         "EP_sens_CP .. EP_risk_unexp_CP, EP_OR_CMLE(_CI)",
         "999 = undefined (EP_RR, EP_RD, EP_LRpos ...)",
         "Type any name in Home to view its value."});
END;

// v1.5: the three touch pages.
es_Help_Grid()
BEGIN
    es_HelpPage("ENTRY GRID & SOFT KEYS",
        {"Modules 1-3 and 5 (each stratum) use a grid.",
         "Tap a cell (or move the red frame with the",
         "arrows and press Enter) and type its count.",
         "Row and column totals and N update as you go.",
         "Soft keys along the bottom:",
         "  Edit    type the selected cell's count",
         "  Values  all four counts in one form (+ key);",
         "          OK runs the analysis at once",
         "  Clear   every cell back to 0",
         "  Cancel  back to the menu (Esc)",
         "  Run     check the table, show the results",
         "Each module keeps its last table until Exit."});
END;

es_Help_Pages()
BEGIN
    es_HelpPage("RESULT PAGES & TAPS",
        {"Turn pages: tap the left or right half of the",
         "screen, or use the arrows; Esc = menu.  A tap",
         "never leaves the first or the last page.",
         "2×2 module, page 5: tap the Odds Ratio, Risk",
         "Ratio or Risk Difference row (or Up/Down) to",
         "see it worked with your own cells, then the",
         "CI step by step.  Any key or tap closes it.",
         "Page 6, the mosaic: bar width = row share of",
         "N, tile height = row %.  Blue = more than",
         "expected, red = fewer (|adj. residual| >= 2).",
         "Tap a tile (or Up/Down) for its O, E, row %.",
         "Page 7: the exact (conditional) odds ratio."});
END;

es_Help_Prev()
BEGIN
    es_HelpPage("PPV VS PREVALENCE, ROC",
        {"Diagnostic module, page 4: PPV (magenta) and",
         "NPV (blue) for THIS test at every prevalence.",
         "Drag along the plot or the slider, or use the",
         "arrows (1 %; Up/Down 10 %); 0 = your sample's",
         "prevalence, the red marker.  +/- = page.",
         "PPV = sens×p / (sens×p + (1-spec)(1-p))",
         "NPV = spec(1-p) / (spec(1-p) + (1-sens)p)",
         "A rare disease makes most positives false.",
         "Page 5: the test as a point on the ROC square:",
         "LR+ = slope of the line from (0,0), LR- =",
         "slope of the line from (1,1); dotted = chance."});
END;

// v1.7: help E, the exact intervals and the exact odds ratio.
es_Help_Exact()
BEGIN
    es_HelpPage("EXACT CIs, EXACT OR",
        {"Exact (Clopper-Pearson) CI of a proportion:",
         "every p under which your count is in neither",
         "2.5 % tail of Binomial(n, p). No normal curve,",
         "and it never covers less than 95 % for any p,",
         "so it is wider than Wilson (conservative).",
         "mid-P counts half of P(X = k): nearer 95 %.",
         "Where: 2×2 Diagnostic tab, risk bars (orange),",
         "diagnostic module page 6 (tap a row).",
         "Conditional MLE OR (2×2, page 7): the OR that",
         "puts the mean of a at your a, all four totals",
         "fixed. R's fisher.test reports it; it sits",
         "nearer 1 than ad/bc when counts are small."});
END;

// v1.7: a help page with a colour for each line (es_HelpPage draws
// them all black): up to 13 lines, 13 px apart from y 30 (the 13th
// ends near y 196, above the footer).  Composed on G1, one blit.
es_CasPage(cgT, cgL, cgC)
BEGIN
    LOCAL cgI;
    DIMGROB_P(G1, 320, 240, #FFFFFFh);
    RECT_P(G1, 0, 0, 319, 239, RGB(245,245,255), RGB(245,245,255));
    RECT_P(G1, 0, 0, 319, 24, RGB(0,60,200), RGB(0,60,200));
    TEXTOUT_P(cgT, G1, 8, 4, 3, RGB(255,255,255));
    FOR cgI FROM 1 TO SIZE(cgL) DO
        IF DIM(cgL(cgI)) > 0 THEN
            TEXTOUT_P(cgL(cgI), G1, 12, 30 + 13 * (cgI - 1), 1, cgC(cgI), 300);
        END;
    END;
    TEXTOUT_P("any key or tap = back", G1, 8, 224, 1, esFOOT);
    BLIT_P(G0, 0, 0, G1);
    RETURN es_WaitAny();
END;

// v1.7: one term of the variance as CAS text: "1/12", or "2/25" for
// 1/(12 + 0.5) when a zero cell made every cell get 0.5 (whole
// numbers only inside CAS text, fence rule 1).
es_SETerm(stV, stH)
BEGIN
    IF stH == 0 THEN RETURN "1/" + es_IStr(stV); END;
    RETURN "2/" + es_IStr(2 * stV + 1);
END;

// v1.7: help F, WHERE THE SE COMES FROM (the delta method), with the
// CAS doing the calculus live:
//   the log odds of a row is g(p) = ln(p/(1-p)); the CAS differentiates
//   it (diff -> 1/p+1/(1-p)) and checks that this is 1/(p(1-p));
//   Var g(p-hat) is about g'(p)^2 Var(p-hat) = 1/(n p (1-p))
//   = 1/(cases) + 1/(non-cases), so row 1 gives 1/a + 1/b, row 2
//   1/c + 1/d, and the rows are independent, so the four add up;
//   the CAS adds the four fractions for the student's table exactly.
// The derivative and its check are the same for every table, so they
// are asked once per run; the fraction once per table (esSEK).  If the
// CAS does not answer, the page shows the textbook form and the
// decimal, with the "decimal" badge.  The table: the last one any
// module analysed (EP_table), or an example if there is none.
es_Help_SE()
BEGIN
    LOCAL hsA, hsB, hsC, hsD, hsH, hsT;
    LOCAL hsS, hsV, hsL, hsF, hsSE, hsOK;
    LOCAL hsC1, hsC2, hsD1, hsN, hsLs, hsB;
    hsL := EP_table;
    hsT := "Your last table";
    IF SIZE(hsL) <> 4 THEN hsL := {0, 0, 0, 0}; END;
    IF MIN(MIN(hsL(1), hsL(2)), MIN(hsL(3), hsL(4))) < 0 OR hsL(1) + hsL(2) + hsL(3) + hsL(4) <= 0 THEN
        hsL := {12, 5, 8, 30}; hsT := "Example table";
    END;
    hsA := ROUND(hsL(1), 0); hsB := ROUND(hsL(2), 0); hsC := ROUND(hsL(3), 0); hsD := ROUND(hsL(4), 0);
    // ---- the calculus: once per run ----
    IF esDvOK == -1 THEN
        esDvT := es_Cas("diff(ln(esq/(1-esq)),esq)");
        esDvC := "";
        IF DIM(esDvT) > 0 THEN esDvC := es_Cas("simplify(diff(ln(esq/(1-esq)),esq)-1/(esq*(1-esq)))"); END;
        esDvOK := 0;
        IF DIM(esDvT) > 0 THEN esDvOK := 1; END;
    END;
    // ---- the four terms, with 0.5 added to every cell if one is 0 ----
    hsH := 0;
    IF hsA * hsB * hsC * hsD == 0 THEN hsH := 1; END;
    hsS := es_SETerm(hsA, hsH) + "+" + es_SETerm(hsB, hsH) + "+" + es_SETerm(hsC, hsH) + "+" + es_SETerm(hsD, hsH);
    hsV := 1 / (hsA + hsH / 2) + 1 / (hsB + hsH / 2) + 1 / (hsC + hsH / 2) + 1 / (hsD + hsH / 2);
    hsSE := hsV^0.5;                     // the number: PPL's (rule 8)
    // ---- the exact fraction: once per table ----
    IF esSEK(1) <> hsA OR esSEK(2) <> hsB OR esSEK(3) <> hsC OR esSEK(4) <> hsD THEN
        esSET := es_Cas(hsS);
        esSEK := {hsA, hsB, hsC, hsD};
    END;
    hsOK := 0;
    IF DIM(esSET) > 0 THEN hsOK := 1; END;
    // ---- the lines ----
    IF esDvOK == 1 THEN
        hsC1 := "CAS: g'(p) = " + es_CasShow(esDvT);
        IF DIM(hsC1) > 46 THEN hsC1 := "CAS: g'(p) has a long form; it equals"; END;
        hsC2 := "  = 1/(p(1-p))";
        IF esDvC == "0" THEN hsC2 := hsC2 + "   (CAS: the difference is 0)"; END;
    ELSE
        hsC1 := "g'(p) = 1/p + 1/(1-p)  (textbook form)";
        hsC2 := "  = 1/(p(1-p))";
    END;
    hsD1 := hsT + ": a = " + STRING(hsA, 2, 0) + ", b = " + STRING(hsB, 2, 0) + ", c = " + STRING(hsC, 2, 0) + ", d = " + STRING(hsD, 2, 0);
    IF hsH == 1 THEN hsD1 := hsD1 + " (+0.5)"; END;
    hsN := "Var = " + hsS;
    IF DIM(hsN) > 46 THEN hsN := "Var = 1/a+1/b+1/c+1/d with these counts"; END;
    IF hsOK == 1 AND DIM(esSET) <= 40 THEN
        hsF := "  = " + esSET;
        hsS := "SE = sqrt(" + esSET + ") = " + STRING(ROUND(hsSE, 4), 2, 4);
        IF DIM(hsS) > 46 THEN hsS := "SE = its square root = " + STRING(ROUND(hsSE, 4), 2, 4); END;
    ELSE
        IF hsOK == 1 THEN
            hsF := "  = an exact fraction with " + STRING(DIM(esSET) - 1, 2, 0) + " digits";
        ELSE
            hsF := "  = " + STRING(ROUND(hsV, 6), 2, 6);
        END;
        hsS := "SE = sqrt(" + STRING(ROUND(hsV, 6), 2, 6) + ") = " + STRING(ROUND(hsSE, 4), 2, 4);
    END;
    // the badge: "exact (CAS)" only when both the calculus and the fraction came
    // from the CAS
    hsB := "decimal";
    IF hsOK == 1 AND esDvOK == 1 THEN hsB := "exact (CAS)"; END;
    hsLs := {"Why is SE(ln OR) = sqrt(1/a+1/b+1/c+1/d)?",
            "A row's log odds: g(p) = ln(p/(1-p)).",
            hsC1, hsC2,
            "Delta method: Var g(" + CHAR(57345) + ") is about g'(p)" + CHAR(178) + " Var(" + CHAR(57345) + ")",
            "  = 1/(n p(1-p)) = 1/(np) + 1/(n(1-p))",
            "  = 1/cases + 1/non-cases: 1/a + 1/b in row 1.",
            "The rows are independent: the four terms add.",
            hsD1, hsN, hsF, hsS, hsB};
    es_CasPage("WHERE THE SE COMES FROM", hsLs,
        {#000000h, #000000h, esMAG, esMAG, esBLUE, esBLUE, esBLUE, esBLUE,
         #000000h, #000000h, esGRN, esGRN, esFOOT});
END;

// v1.6: the two new help pages (items C and D in the Help menu).
es_Help_Read()
BEGIN
    es_HelpPage("READINGS & FISHER BARS",
        {"2×2 module, page 1: the same four counts read",
         "three ways.  Tap a tab (or Up/Down):",
         "Cohort: rows = exposure; RR, OR, RD, AF.",
         "Diagnostic: rows = test, columns = disease;",
         "sens, spec, PPV, NPV, LR, Youden, error rate.",
         "Paired: rater A by rater B (or before/after);",
         "kappa, McNemar (b vs c only), Yule's Q and Y.",
         "Page 4: every table with your margins, one bar",
         "each.  Red = yours; magenta = no more likely",
         "than yours: their sum is Fisher's p.  Grey =",
         "more likely.  Tap a bar (or Up/Down): its table.",
         "From Home: EpiStats2x2(a,b,c,d) -> Fisher p."});
END;

es_Help_Forest()
BEGIN
    es_HelpPage("FOREST, RISK BARS, NNT",
        {"Stratified, page 3: one row per stratum, then",
         "the crude and the M-H estimate, log scale.",
         "Square size = M-H weight; bar = 95% CI;",
         "magenta diamond = M-H and its CI; dotted = 1.",
         "Tap a stratum (Up = 1, Down = 2): drop/restore.",
         "Risk module, page 4: each group's risk with a",
         "95% Wilson CI; the magenta bracket is the RD.",
         "OR/RR = (1-Ru)/(1-Re): the OR overstates the",
         "RR when the outcome is common.",
         "Page 5: NNT = 1/|RD| rounded up: that many",
         "people (blue); red = the one extra case,",
         "green = the one case prevented."});
END;
