Skip to content

The sample workspace

Create the sample workspace, on the workbench's welcome screen (File › New Workspace brings it up), writes a sample into a new folder, Community Lab in your Documents folder unless you choose another place: four provinces, two experiments, four scripts and a mortality table, every one of them runnable as it stands. Every file is generated from the engine's own templates, presets and defaults, so the sample cannot describe a parameter, a template or a table the engine does not have, and the test suite checks every province and experiment file against the engine and compiles every script.

File What it is
README.md What is in the workspace and how to run it.
scenarios/baseline.province.json The default province.
scenarios/ageing.province.json An older age profile, a TFR of 1.6 and faster mortality improvement.
scenarios/pandemic.province.json The default province living through a year of pandemic mortality from its second year.
scenarios/stressed-basis.province.json The default province living on bases/stressed-sa-2024.csv.
experiments/sam-life-stresses.experiment.json SAM's life underwriting stresses on the burial society, lived through: mortality +15% at every age, and a catastrophe month.
experiments/old-age-grant.experiment.json A 20% higher older persons grant: what it costs and what it buys.
scripts/first-look.js Two simulated years and a look round: the indicators, who lives where, the last births and deaths.
scripts/ae-by-age.js A/E by ten-year band over five years, a plot, and the table saved as CSV.
scripts/pooled-experience.js Sixteen seeds pooled on the worker pool: crude rates against the basis.
scripts/premium-check.js The funeral premium cut and raised by a fifth, on six seeds.
bases/stressed-sa-2024.csv The sa-2024 preset, 15% heavier at every age.

The files follow, exactly as the sample writes them.

The provinces

scenarios/baseline.province.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
  "title": "The default province",
  "notes": "Unity Province on the default basis: published South African mortality (sa-2024) and fertility, the 2026/27 tax tables, a repo rate of 6.75%.",
  "seed": "agincourt-12",
  "basis": {}
}
scenarios/ageing.province.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
  "title": "An ageing province",
  "notes": "An older age profile, fertility well below replacement and faster mortality improvement: more pensioners, fewer workers, a heavier grant bill.",
  "seed": "ageing-1",
  "basis": {
    "ageProfile": "ageing",
    "tfr": 1.6,
    "mortalityImprovement": 0.015
  }
}
scenarios/pandemic.province.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
  "title": "A pandemic year",
  "notes": "The default province, living through a year of pandemic mortality from its second year: every age a quarter heavier, and those of sixty and over a further 44%.",
  "seed": "agincourt-12",
  "basis": {},
  "shocks": [
    {
      "kind": "mortality",
      "label": "Pandemic: all ages ×1.25",
      "fromMonth": 12,
      "months": 12,
      "factor": 1.25
    },
    {
      "kind": "mortality",
      "label": "Pandemic: 60 and over a further ×1.44",
      "fromMonth": 12,
      "months": 12,
      "factor": 1.44,
      "minAge": 60
    }
  ]
}
scenarios/stressed-basis.province.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
  "title": "Living on a supplied table",
  "notes": "The province lives on bases/stressed-sa-2024.csv instead of its preset: every death channel follows the table, and A/E is measured against it.",
  "seed": "agincourt-12",
  "basis": {},
  "mortality": "bases/stressed-sa-2024.csv"
}

See Province files for every field.

The experiments

experiments/sam-life-stresses.experiment.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/experiment.json",
  "notes": "SAM life underwriting stresses, lived through: mortality +15% at every age for the whole run, and a catastrophe month of 1.5 extra deaths per 1,000 lives.",
  "title": "SAM life stresses on the burial society",
  "question": "Does the burial society survive the standard formula's life stresses, lived through rather than assumed?",
  "audience": "actuarial",
  "arms": [
    {
      "id": "mortality-15",
      "label": "Mortality +15% on every age",
      "shocks": [
        {
          "kind": "mortality",
          "label": "SAM mortality stress +15%",
          "fromMonth": 0,
          "months": 120,
          "factor": 1.15
        }
      ]
    },
    {
      "id": "catastrophe",
      "label": "Catastrophe: +1.5‰ of lives in one month",
      "shocks": [
        {
          "kind": "mortality",
          "label": "SAM catastrophe month",
          "fromMonth": 12,
          "months": 1,
          "factor": 2.8
        }
      ]
    }
  ],
  "seeds": 8,
  "years": 10
}
experiments/old-age-grant.experiment.json
{
  "$schema": "https://intelligentactuaries.com/schemas/community-lab/experiment.json",
  "notes": "A 20% higher older persons grant: what it costs the fiscus, and what it buys in poverty and health.",
  "title": "Raise the old-age grant",
  "question": "What does a 20% higher older persons grant cost the fiscus, and what does it buy in poverty and health?",
  "audience": "government",
  "arms": [
    {
      "id": "grant-up",
      "label": "Old-age grant +20% (R2,880)",
      "params": {
        "oldAgeGrant": 2880
      }
    }
  ],
  "seeds": 8,
  "years": 10
}

See Experiment files for every field and how the result is read.

The scripts

scripts/first-look.js
// A first look: build the province, live two years, and read it.
// Run it with Ctrl+Enter (Cmd+Enter on a Mac). Two simulated years take about ten seconds.

const p = await province({ seed: 'first-look' });
print(`${p.basis.regionName} Province on ${p.date}: ${p.people().length} residents in ${p.households().length} households. Basis ${p.basisHash}.`);

p.run({ years: 2 });
print(`Two years on, ${p.date}:`);

const ind = p.indicators();
const show = ['population', 'deaths_per_1000', 'ae', 'e0', 'scheme_reserve', 'poverty', 'unemployment', 'gini'];
table(METRICS.filter((m) => show.includes(m.id)).map((m) => ({ indicator: m.label, value: ind[m.id], unit: m.unit })));

// Who lives where
const byCity = {};
for (const person of p.people()) byCity[person.city] = (byCity[person.city] ?? 0) + 1;
table(Object.entries(byCity).map(([city, residents]) => ({ city, residents })));

// What happened: the last few births and deaths in the ledger
table(p.events().filter((e) => e.kind === 'birth' || e.kind === 'death').slice(-8));
scripts/ae-by-age.js
// Actual against expected deaths by age band, on the basis the province was built on.
// Five simulated years, about twenty seconds. One province is about 400 people, so the
// bands are noisy: scripts/pooled-experience.js pools seeds on the worker pool instead.

const p = await province({ seed: 'ae-by-age' });
p.run({ years: 5 });

const bands = {};
for (const r of p.experience({ ageWidth: 10 })) {
  const b = (bands[r.age] ??= { band: `${r.age}-${r.age + 9}`, person_years: 0, deaths: 0, expected: 0 });
  b.person_years += r.person_years;
  b.deaths += r.deaths;
  b.expected += r.expected_deaths;
}
const rows = Object.values(bands).map((b) => ({ ...b, ae: b.expected > 0 ? b.deaths / b.expected : null }));
table(rows, ['band', 'person_years', 'deaths', 'expected', 'ae']);
plot({ x: rows.map((b) => b.band), series: { 'A/E': rows.map((b) => b.ae) }, title: 'Actual / expected deaths by age band', yLabel: 'A/E' });

const D = rows.reduce((a, b) => a + b.deaths, 0);
const E = rows.reduce((a, b) => a + b.expected, 0);
print(`Pooled A/E ${(D / E).toFixed(3)}: ${D} deaths against ${E.toFixed(1)} expected.`);

await writeFile('results/ae-by-age.csv', csv(rows));
print('Saved results/ae-by-age.csv');
scripts/pooled-experience.js
// Experience pooled over seeds: the same basis lived 16 times on the worker pool,
// enough deaths for a period table. About three minutes with eight workers.

const exp = await pooledExperience({ seeds: 16, years: 10, ageWidth: 5 });
print(exp.headline.map((h) => `${h.label}: ${h.value}`).join(' · '));

// Crude central rates against the true basis, men, by five-year band
const cells = {};
for (const r of exp.rows.filter((r) => r.sex === 'M')) {
  const c = (cells[r.age] ??= { age: r.age, deaths: 0, person_years: 0, expected: 0 });
  c.deaths += r.deaths;
  c.person_years += r.person_years;
  c.expected += r.expected_deaths;
}
const rows = Object.values(cells).filter((c) => c.person_years > 0).map((c) => ({
  age: c.age,
  m_actual: c.deaths / c.person_years,
  m_basis: c.expected / c.person_years,
  deaths: c.deaths,
}));
plot({ x: rows.map((r) => r.age), series: { experience: rows.map((r) => r.m_actual || null), basis: rows.map((r) => r.m_basis) }, title: 'Men: crude death rates against the basis', yLabel: 'deaths per person-year', log: true });
await writeFile('results/pooled-experience.csv', csv(exp.rows));
print(`Saved ${exp.rows.length} cells to results/pooled-experience.csv, with the true basis in the export's provenance.`);
scripts/premium-check.js
// Is the funeral premium adequate? The lab's template, run on the worker pool:
// the baseline and two arms (the premium cut and raised by a fifth) on the same
// 6 seeds for 8 years. About two minutes with eight workers.

const spec = template('premium-adequacy', 8);
spec.seeds = 6;
const r = await experiment(spec);

const m = (id) => METRICS.find((x) => x.id === id);
table(
  r.arms.slice(1).flatMap((arm) =>
    ['scheme_reserve', 'scheme_ruin', 'loss_ratio'].map((id) => ({
      arm: arm.label,
      indicator: m(id).label,
      baseline: r.arms[0].metrics[id].mean,
      effect: arm.effects[id].mean,
      lo95: arm.effects[id].lo,
      hi95: arm.effects[id].hi,
    })),
  ),
);
print('An interval that does not cross zero is an effect these seeds can tell from the province\'s own randomness.');

See the Script API for every function they use.

The mortality table

bases/stressed-sa-2024.csv is the sa-2024 preset with every q multiplied by 1.15, by single age and sex. Its first rows:

bases/stressed-sa-2024.csv
age,qx_m,qx_f
0,0.028326,0.024522
1,0.001885,0.003986
2,0.000345,0.001037
3,0.000209,0.000352
4,0.000206,0.000151
5,0.000221,0.000084
6,0.000240,0.000062

See Mortality tables for the format.