schism devlog - 06/09
This cycle was mostly about making the sim produce something believable instead of a single root religion that never really splits. I found a dumb averaging bug, fixed the schism dynamics so breakaway sects survive long enough to matter, gave religions names that read like a family tree, and then hit a wall on speed.
The mean heterodoxy bug
The simulation kept doing something wrong: heterodoxy would basically vanish within 30 generations, and right before that it would blow up entirely:
on generation 49
Error: tried creating child het distr
Caused by:
alpha is not positive in beta distribution
The cause was embarrassingly simple. I was computing mean_heterodoxy by dividing over the total adherent length, which included dead people. Death is a soft delete in this thing, so all those Dead adherents were still dragging the mean toward zero. Once it bottomed out, the beta distribution for child heterodoxy got a non-positive alpha and the whole run died.
Fixing the denominator fixed it. The mean now slowly increases instead of collapsing:
on generation 149
{
"totals": {
"people": 540558,
"religions": 1,
"active": 1,
"extinct": 0,
"new_this_generation": 0,
"mean_heterodoxy": 0.325379466124453
},
"religions": [
{ "name": "Church of Orange", "adherents": 540558, "status": "active", "age": 3000, "parent": "none", "new": false }
]
}
While I was in there I also stopped heterodoxy from changing over a person's life for now, and sped memory up by dropping all adherents who died last generation out of self.adherents before running the rest of tick(). No reason to keep looping over the dead.
The real problem was conversion, not schism
I had the schism rate cranked but still got basically nothing useful. Walking through how schism actually works, it's two stages each tick. First Religion::should_schism decides if a religion cracks at all, a yes/no coin flip gated on the congregation being at least 50 people. The chance is:
chance = schism_base_rate × mean_heterodoxy × (1 + high_het_share) × population_factor
Then, if a crack happens, Adherent::try_conversion offers every member of the parent religion a chance to walk out into the new sect:
// adherent.rs:75
probability = heterodoxy × conversion_base_rate // 0.02
flip_weighted_coin(probability)
That 0.02 was the whole problem. Out of 500 members averaging 0.25 heterodoxy, the expected number who convert is 500 × (0.25 × 0.02) ≈ 2–3 people. So a schism fires, but the new sect is born with two or three people, far below the min_congregation = 50 gate. It can never schism again and it dies in a generation or two. What I was actually building was a big stable root continuously shedding tiny stillborn splinters. Raising schism_base_rate just made more stillborn splinters.
So the bottleneck wasn't how often religions crack, it was how much mass walks out when they do. Real schisms peel off a meaningful bloc, not three people. I raised conversion_base_rate to 0.7. On its own that did nothing, because the schism rate hadn't changed. But once a schism did fire, it finally produced a sect with enough mass to survive:
on generation 149
{
"totals": {
"people": 571125,
"religions": 2,
"active": 2,
"extinct": 0,
"new_this_generation": 0,
"mean_heterodoxy": 0.32430263159084216
},
"religions": [
{ "name": "Church of Orange", "adherents": 539284, "status": "active", "age": 3000, "parent": "none", "new": false },
{ "name": "True Church of Orange", "adherents": 31841, "status": "active", "age": 1240, "parent": "Church of Orange", "new": false }
]
}
Worth noting why mean_heterodoxy and high_het_share are near-constant and not doing much work: heterodoxy drift over a lifetime is commented out right now, and births regress toward the population mean (0.6 × parent + 0.4 × pop_mean), which holds the average near 0.25 instead of letting heretical lineages compound. So schism_base_rate and population_factor are doing all the real work. That's fine for now, but it's the next thing to revisit if I want the heterodox wing to actually be the part that breaks away.
Procedural naming
The first thing that bugged me once sects survived was the names. I got "True Church of Orange" eight times in a row. Useless for reading lineages.
What I wanted was a system where the root is something unique, and every split is adjective {root} or some similar format, so a child always reads as vaguely similar to its parent. That makes the tree legible at a glance:
{
"religions": [
{ "name": "Spiritbinding", "adherents": 513747, "status": "active", "age": 3000, "parent": "none", "new": false },
{ "name": "Sanctified Spiritbinding", "adherents": 174, "status": "active", "age": 400, "parent": "Spiritbinding", "new": false },
{ "name": "Terrestrial Spiritbinding", "adherents": 151, "status": "active", "age": 2020, "parent": "Spiritbinding", "new": false },
{ "name": "Lay Spiritbinding", "adherents": 0, "status": "extinct", "age": 140, "parent": "Spiritbinding", "new": false }
]
}
You can read that straight down. With this in place I bumped the generation count to 200 and got grandchildren:
{
"religions": [
{ "name": "Spiritbinding", "adherents": 6184721, "status": "active", "age": 4000, "parent": "none", "new": false },
{ "name": "Terrestrial Spiritbinding", "adherents": 2910, "status": "active", "age": 3020, "parent": "Spiritbinding", "new": false },
{ "name": "Sanctified Spiritbinding", "adherents": 2163, "status": "active", "age": 1400, "parent": "Spiritbinding", "new": false },
{ "name": "Lay Spiritbinding", "adherents": 0, "status": "extinct", "age": 140, "parent": "Spiritbinding", "new": false },
{ "name": "Monastic Terrestrial Spiritbinding", "adherents": 0, "status": "extinct", "age": 20, "parent": "Terrestrial Spiritbinding", "new": false },
{ "name": "Liberal Terrestrial Spiritbinding", "adherents": 0, "status": "extinct", "age": 20, "parent": "Terrestrial Spiritbinding", "new": false }
]
}
Grandchildren happen now, but they go extinct immediately. I think when a split comes off a subsect that's already small, the death rate catches up before it can build any mass. Same survival problem as before, one level down.
I also tried adding heterodoxy change over a lifespan back in as an experiment. Whatever it was doing immediately tanked total heterodoxy, and all I got was one split that died right away. Pulled it back out so I can rework it properly later.
Better readout
The readout needed to be easier to scan. I gave each religion a canonical founding date and an extinction date, and sorted so newer ones float to the top and extinct ones sink to the bottom:
"religions": [
{
"name": "Sanctified Spiritbinding",
"adherents": 174,
"status": "active",
"founding_date": 2600,
"extinction_date": null,
"age": 400,
"parent": "Spiritbinding"
},
{
"name": "Lay Spiritbinding",
"adherents": 0,
"status": "extinct",
"founding_date": 1240,
"extinction_date": 1380,
"age": 140,
"parent": "Spiritbinding"
},
{
"name": "Terrestrial Spiritbinding",
"adherents": 151,
"status": "active",
"founding_date": 980,
"extinction_date": null,
"age": 2020,
"parent": "Spiritbinding"
},
{
"name": "Spiritbinding",
"adherents": 513747,
"status": "active",
"founding_date": 0,
"extinction_date": null,
"age": 3000,
"parent": "none"
}
]
Plumbing
A bunch of non-glamorous structural work landed this cycle too. I migrated to a feature-based file structure, centralized all the config type vars into one spot, moved unit-interval parsing and validation into a serde Deserialize, switched f32 to f64 for better compat, and cleaned out dead code. I also moved everything into an apps/ directory and did an initial CDK setup, so there's now somewhere for infra to live.
Sim speed
The thing blocking me now: I can't get past ~200 generations without it being prohibitively slow.
I ran a flamegraph and found most of the work is split between printing and, if I comment out printing, hashing. Specifically standard hashing and hashmap access.
My read is that yes, a hashmap is O(1), but O(1) still does real work, and once I'm looping over a million-plus adherents per tick the constant factor stops being free. Building new hashmaps and hashing keys over and over is the cost. I think it's actually faster to drop to a Vec, know each person's index, and address them directly. That's the kind of thing ECS and game-engine storage patterns are built to handle, so that's where I'm looking next.