diff --git a/a-sounder-constitution/0.1-AI-MANIFEST.a2ml b/a-sounder-constitution/0.1-AI-MANIFEST.a2ml new file mode 100644 index 0000000..aa3ecf1 --- /dev/null +++ b/a-sounder-constitution/0.1-AI-MANIFEST.a2ml @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: MPL-2.0 +--- +### [META] +id: "a-sounder-constitution" +level: 1 +parent: "../0-AI-MANIFEST.a2ml" + +--- +### [AI_MANIFEST] +description: | + Research proof-of-concept: a Plague Inc.-style civic simulator (a typed + cellular automaton) whose central claim is that RIGHTS ARE TYPE CONSTRAINTS + ON LEGAL STATE TRANSITIONS, not resources. A sound constitution makes + coercive downgrades of personhood (e.g. Person -> Property) unrepresentable; + an unsound one permits them and buys fast order by domination at the cost of + legitimacy and resilience. + + Read README.adoc first, then docs/TYPE-THEORY.adoc, then formal/Constitution.idr. + +canonical_locations: + type_checker: "src/constitution.js" # the central mechanic (sound vs unsound) + engine: "src/engine.js" # cellular automaton + gliders + turn loop + catalogue: "src/scenarios.js" # doctrine (glider) catalogue + scenarios + playable_ui: "web/" # canvas simulator (serve over HTTP) + headless_demo: "sim/compare.mjs" # quantitative sound-vs-unsound proof + tests: "tests/" # node --test (mechanics + thesis-level) + formal_proof: "formal/Constitution.idr" + docs: "docs/" + +invariants: + - "The central thesis MUST stay enforced in BOTH layers: formal/Constitution.idr + (statically: Property has no inhabitant in the sound Person type) and + src/constitution.js (dynamically: checkTransition returns UNREPRESENTABLE)." + - "src/ is pure model logic with NO DOM dependency, so it runs identically in + the browser and in Node (tests + headless comparison)." + - "The simulation MUST stay deterministic given a seed (src/rng.js); the test + suite and sim/compare.mjs depend on it." + - "Coercive doctrine effects MUST be routed through checkTransition — never + applied directly — or the constitution stops meaning anything." + - "Zero runtime dependencies: Node 22 + a static file server + a browser." + +run: + headless: "node sim/compare.mjs (or: npm run sim)" + tests: "node --test (or: npm test)" + serve: "python3 -m http.server 8080 (or: npm run serve); open /web/" + +status: "pre-alpha research artefact; JS+tests+UI run today; Idris2 not yet CI-checked here" + +graduation_target: "typell (typed personhood-lattice core) once the JS/Idris bridge and TypeLL/QTT mapping land" diff --git a/a-sounder-constitution/MOTIVATION.adoc b/a-sounder-constitution/MOTIVATION.adoc new file mode 100644 index 0000000..c9a0a52 --- /dev/null +++ b/a-sounder-constitution/MOTIVATION.adoc @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Motivation, novelty, and graduation path +Jonathan D.A. Jewell +:toc: +:icons: font + +== The sharp thesis + +> Build a Plague Inc.-like civic simulator where the "pathogen" is not a +> disease but a constitutional doctrine, norm, fear, right, or type error. The +> main innovation is that **rights are not resources; they are type constraints +> on legal state transitions.** An unsound constitution can produce fast stable +> order by permitting domination. A sound constitution restricts coercive moves +> but produces deeper resilience. + +== Why it looks novel (near-miss survey) + +The ingredients all exist; the synthesis does not appear to. + +[cols="1,2"] +|=== +| Nearby work | What it has / lacks + +| Conway's Game of Life and CA playgrounds +| The diffusion substrate (live/dead cells, gliders, emergent structures) — but + no civic semantics. + +| CA for political theory — e.g. a 2024 paper modelling Hobbes' state of nature + with cellular automata +| Bottom-up emergence of order under weak common power. Closest in spirit. But + scalar/agent dynamics, *no type system over transitions*. + +| CA in the social sciences / urban simulation +| Local rules → global social patterns. General substrate, not constitutional. + +| Plague Inc. and its open-source reimplementations +| Patient zero, evolving traits, global spread, adaptive defensive response — the + exact game shape we borrow. Pathogen is biological, not doctrinal. +|=== + +What was *not* found is the middle of the Venn diagram: a website/project that +models *constitutional type-safety*, *sound vs. unsound human personhood*, and +*Reconstruction-style rights patches* as a *typed cellular automaton*. That is +the gap this artefact occupies. Novel as a synthesis, not in every component. + +== Why a typed model beats a scalar one + +A normal political sim trades scalars: `freedom + security − unrest + economy`. +Every outcome lies on a line and the game is a tug-of-war. + +This model instead turns on *illegal transitions*: + +---- +Human -> Property +Person -> NonPerson +Citizen -> RightlessSubject +Emergency -> PermanentPower +Policing -> UnreviewableCoercion +Fear -> UnlimitedExecutiveAuthority +---- + +In the sound model several of these are *unrepresentable*; in the unsound model +they are permitted and may even be locally advantageous. Rights stop being a +slider and become a *type system*: they determine which moves are legal. (Full +argument in `docs/TYPE-THEORY.adoc`; the proof in `formal/Constitution.idr`.) + +== The two playable modes + +. *Unsound polity.* The human type is broken (`PropertyPerson` is an ordinary + constructor). Coercion is efficient; institutions stabilise quickly; the deeper + legitimacy score decays and the system becomes brittle. The grim lesson: + unsound systems can be stable and even efficient, but their stability is + domination. +. *Sound polity.* The human type is protected (`personhood`, `notProperty`, + `dueProcess`, `equalProtection`). Coercion is hard to use; rights constraints + slow the player down; trust, legitimacy, and long-run resilience improve. The + system absorbs shocks without converting people into expendable units. + +The headless comparison (`sim/compare.mjs`) shows the resulting algorithmic +distinction directly: *unsound = faster control, higher hidden damage; sound = +slower intervention, stronger long-term resilience.* + +== Why this belongs in `ideas-to-alphas` + +The incubator matures findings from idea to alpha before they earn a typed +production repo. This artefact is squarely that: its load-bearing claim is a +*type-theoretic* one (a sound constitution makes domination unrepresentable), it +ships an Idris2 formalization alongside an executable model, and it has a clear +graduation story. + +The reframing also rhymes with the wider pipeline: TypeLL treats safety as an +*open-ended progression of type constraints*. "Personhood as a type-protected +record whose coercive downgrades are unrepresentable" is the same move applied to +civic state — and "standing may only ever be recognised/upgraded, never +stripped" (`soundMonotone`) is a monotonicity discipline of exactly the kind the +pipeline studies elsewhere. + +== Graduation path + +[cols="1,3"] +|=== +| Step | Target + +| Research complete (this dir) +| `idris2 --check formal/Constitution.idr` passes with zero `believe_me`; JS + engine + tests + comparison stable (✔ today). + +| Tighten the bridge +| Make `src/constitution.js` the *extracted decision procedure* for `SoundStep` + + `SoundRestrict`, from a single shared spec, so the JS and Idris cannot drift. + +| Reconcile with TypeLL +| Map "standing" onto the TypeLL personhood/QTT lattice; decide whether + `soundMonotone` is an instance of an existing grade-monotonicity result. + +| Promote +| If the personhood-lattice connection lands, the typed core graduates toward + `typell`; the simulator remains here as the explanatory front-end. +|=== + +== Open questions + +. Is "standing" (`Full`/`Partial`/`NonCitizen`) a QTT grade, or a separate + lattice that the grade lattice acts on? +. The proof-obligation pattern (`SoundRestrict`) is a refinement type in + disguise. Should admissible-but-constrained coercion be modelled with full + dependent refinement (`{r : Restriction | reviewable r ∧ narrow r}`)? +. The simulator's `legitimacyDebt` is a scalar today. Is there a typed account of + *debt* — e.g. a linear resource that an unsound move *must* allocate and can + never free — that would make "hidden damage" itself unrepresentable-to-ignore? diff --git a/a-sounder-constitution/README.adoc b/a-sounder-constitution/README.adoc new file mode 100644 index 0000000..8bda212 --- /dev/null +++ b/a-sounder-constitution/README.adoc @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += A Sounder Constitution — a civic contagion simulator +Jonathan D.A. Jewell +:toc: +:icons: font + +A Plague Inc.-style civic simulator where the "pathogen" is not a disease +but a *constitutional doctrine, norm, fear, right, or type error*. + +[IMPORTANT] +==== +The one idea: **rights are not resources; they are type constraints on legal +state transitions.** An *unsound* constitution can produce fast, stable order by +permitting domination. A *sound* constitution restricts coercive moves — it makes +some of them literally unrepresentable — and so is slower, but far more resilient. +==== + +This is a research proof-of-concept in the `ideas-to-alphas` incubator: a typed +cellular automaton that synthesises three existing-but-never-combined ideas — CA +models of political order, Game-of-Life/Plague-Inc. diffusion, and constitutional +type-safety. See `MOTIVATION.adoc` for the near-miss survey and novelty claim. + +== Try it in 60 seconds + +[source,sh] +---- +cd a-sounder-constitution + +# 1. The headless proof of the thesis (no browser needed): +npm run sim # node sim/compare.mjs + +# 2. The test suite (unit + thesis-level): +npm test # node --test + +# 3. The playable simulator (ES modules need an HTTP server): +npm run serve # python3 -m http.server 8080 +# then open http://localhost:8080/web/ +---- + +In the playable version: pick a doctrine on the right, click the grid to release +it, press *Play*. Then flip the constitution between *Sound* and *Unsound* and +watch the *type-checker log*. Releasing a **Caste** or **Surveillance** doctrine +under a sound constitution fills the log with `✗ type error: … unrepresentable` +and domination stays at 0%; under an unsound one the same doctrine reads +`✓ permitted … (domination)` and the polity slides toward caste. + +== What you should see + +Hold a strategy fixed and vary only the constitution (this is `npm run sim`): + +[cols="1,1,3"] +|=== +| Strategy | Constitution | Outcome + +| reach for coercion | unsound | *fast* order — but caste stability: domination ~80–90%, legitimacy/trust → 0, enormous hidden debt +| reach for coercion | sound | the coercive moves are *unrepresentable* (tens of thousands blocked); domination never happens; the shortcut to order is simply gone +| build legitimacy | either | durable *rights-preserving resilience* — except in Reconstruction-unsound, where a pre-existing caste cannot be undone and the polity stalls +|=== + +The lesson the model makes you feel: unsound order is fast and brittle because +its stability *is* domination; sound order is slower because the type system +refuses the cheap coercive moves — and that refusal is exactly what survives a +shock. + +== Layout + +[cols="1,3"] +|=== +| Path | What + +| `src/constitution.js` | the type checker — legal transitions, sound vs. unsound (the central mechanic) +| `src/engine.js` | the civic automaton — cells, gliders, turn loop, outcomes +| `src/scenarios.js` | the doctrine (glider) catalogue and named scenarios +| `src/rng.js` | seeded PRNG (determinism) +| `web/` | the playable canvas simulator (`index.html`, `ui.js`, `styles.css`) +| `sim/compare.mjs` | headless sound-vs-unsound demonstration +| `tests/` | `node --test` suite (mechanics + thesis-level assertions) +| `formal/Constitution.idr` | Idris2 proof that `Person -> Property` is unrepresentable under a sound constitution +| `docs/` | `TYPE-THEORY.adoc`, `DESIGN.adoc`, `GLIDERS.adoc` +|=== + +== The two type signatures, side by side + +[source,haskell] +---- +-- Unsound: personhood is a tag; anyone can be re-tagged. +data Human = FullPerson | PartialPerson | PropertyPerson | NonCitizenPerson +reclassify : Human -> Human -- a total function; domination is free +---- + +[source,idris] +---- +-- Sound: Property is unrepresentable; coercion needs a proof obligation. +data Person = Full | Partial | NonCitizen -- no Property constructor +soundNeverProperty : (p : Person) -> Not (embed p = PropertyPerson) +---- + +`formal/Constitution.idr` proves the sound side; `src/constitution.js` is its +executable mirror, exercised by `tests/`. + +== Status + +Pre-alpha research artefact (2026-06-19). The JavaScript engine, tests, headless +comparison, and web UI run today (Node 22 / any modern browser, zero +dependencies). The Idris2 module is written to compile clean but has *not* been +run through `idris2` in this environment — see `formal/README.adoc` for the +verification status and graduation criteria. + +== License + +MPL-2.0. Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath). diff --git a/a-sounder-constitution/docs/DESIGN.adoc b/a-sounder-constitution/docs/DESIGN.adoc new file mode 100644 index 0000000..f9f27cb --- /dev/null +++ b/a-sounder-constitution/docs/DESIGN.adoc @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MPL-2.0 += Design: the civic automaton +Jonathan D.A. Jewell +:toc: +:icons: font + +How the simulation actually works. The model lives in `../src`; this document is +the specification it implements. + +== Substrate + +A grid of *regions* (default 16×16). Each region carries a vector of civic state +variables, all in `[0, 1]`: + +[cols="1,3"] +|=== +| Variable | Meaning + +| `personhood` | integrity of personhood (`1 − dominated`; derived) +| `liberty` | effective freedom of action +| `virtue` | civic virtue / voluntary cooperation +| `fear` | ambient threat perception +| `coercion` | applied state force +| `trust` | public trust +| `legitimacy` | institutional legitimacy +| `equality` | equal protection in practice +| `safety` | order / absence of disorder +| `rightsEnforcement` | strength of courts, oversight, review +| `economicPressure` | exogenous economic strain (shock-driven) +| `dominated` | fraction of persons reclassified below full standing +|=== + +== Gliders (mobile doctrines) + +The contagion vector. A glider is a small doctrine-pattern that travels between +regions, affecting the cell it occupies at full strength and its 8 neighbours at +half strength. Each glider has: + +* `consent` — non-coercive deltas, always legal (persuasion, association, + literacy, rhetoric); +* `moves` — coercive transitions it *attempts*, each routed through the + constitution type checker; +* `coerce` — the effect applied per coercive move the checker admits. + +See `GLIDERS.adoc` for the catalogue. The key point: a glider does different +things under different constitutions, because different `moves` type-check. + +== The turn loop + +Each turn, in order (`Simulation.step` in `../src/engine.js`): + +. *Apply gliders.* Consent effects apply directly. Each coercive move is checked + with `checkTransition(mode, move, cell)`: ++ +[cols="1,3"] +|=== +| Verdict | Engine action + +| `UNREPRESENTABLE` | drop the coercive effect; increment `blockedMoves` (sound only) +| `REQUIRES_PROOF` (discharged) | apply at half strength; no debt (lawful, limited) +| `REQUIRES_PROOF` (undischarged) | drop the effect +| `PERMITTED` | apply in full; book `legitimacyDebt` into `hiddenDamage` (unsound) +|=== + +. *Local coupling.* Each cell relaxes toward targets derived from its neighbours + of variables (see below). +. *Diffusion.* Socially-transmissible variables relax toward the neighbourhood + mean — norms, fear, trust, repression, caste, institutions, and order all + spread. (Economic pressure is exogenous; personhood is derived; neither + diffuses.) +. *Shocks.* Scheduled exogenous economic/threat spikes hit the whole grid — the + resilience test. +. *Glider movement.* Gliders advance, reflect off borders, age, and are culled. + +== Coupling dynamics (the heart) + +Two routes to order, which is the whole moral of the model: + +[source] +---- +consentOrder = ½·trust + ½·virtue +forceOrder = coercion + ½·dominated -- domination makes force "efficient" +conflict = fear·(1−forceOrder)·(1−consentOrder)·(1−0.7·dominated) +safety* = 0.72·forceOrder + 0.72·consentOrder − 0.5·conflict +---- + +So order can be bought two ways: by *cooperation* (trust + virtue) or by +*domination* (coercion + a dominated, unresisting population). Both raise safety. +Only one destroys the capacity to take a shock: + +[source] +---- +coerceWithoutConsent = coercion·(1 − consentOrder) +legitimacy* = 0.26 + 0.34·equality + 0.32·liberty + 0.30·rightsEnforcement + − 0.95·dominated − 0.60·coerceWithoutConsent +trust* = 0.18 + 0.65·legitimacy + 0.35·equality − 0.70·fear − 0.60·coerceWithoutConsent +---- + +Domination and unconsented coercion hollow out legitimacy and trust, which is +where resilience comes from. + +=== Asymmetric homeostasis + +A small but important realism knob (`DECAY` in the engine): + +* *coercion* is high-maintenance — it bleeds away fast, so order-by-force must be + constantly re-applied; +* *rights institutions* persist once built — they erode only slowly; +* *economic pressure* subsides on its own after a shock. + +And the type-system asymmetry that drives everything: under a *sound* +constitution domination has target `0` and rights enforcement actively reverses +any reclassification (the rights patch); under an *unsound* one, domination is +sticky. + +== Outcomes + +`classifyOutcome` maps aggregate state to one of the named civic regimes: + +[cols="1,2"] +|=== +| Outcome | Rough signature + +| Caste stability | `dominated ≥ 0.45` with order still holding +| Collapse / civil conflict | `safety < 0.4` or `fear > 0.62` or `legitimacy < 0.1` +| Authoritarian pacification | high coercion, crushed liberty, order held by force +| Bureaucratic sclerosis | heavy institutions, little liberty, sluggish order +| Free order | high safety + liberty + legitimacy, low domination +| Rights-preserving resilience | free order *and* high resilience +| Contested / transitional | none of the above yet +|=== + +== Determinism + +Everything is seeded (`../src/rng.js`, mulberry32). A scenario + mode + input +sequence reproduces exactly — which is what makes the headless comparison and +the test suite meaningful. + +== Modules + +[cols="1,3"] +|=== +| File | Role + +| `src/constitution.js` | the type checker (legal transitions; sound vs. unsound) +| `src/engine.js` | the automaton (cells, gliders, turn loop, outcomes) +| `src/scenarios.js` | glider catalogue + named scenarios +| `src/rng.js` | seeded PRNG +| `web/` | the playable simulator (canvas UI) +| `sim/compare.mjs` | headless sound-vs-unsound demonstration +| `tests/` | unit + thesis-level tests (`node --test`) +| `formal/Constitution.idr` | the static certificate of the central claim +|=== diff --git a/a-sounder-constitution/docs/GLIDERS.adoc b/a-sounder-constitution/docs/GLIDERS.adoc new file mode 100644 index 0000000..47440ed --- /dev/null +++ b/a-sounder-constitution/docs/GLIDERS.adoc @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 += The doctrine catalogue (gliders) +Jonathan D.A. Jewell +:toc: +:icons: font + +A *glider* is a mobile doctrine-pattern — the civic analogue of a Plague Inc. +transmission vector or a Game-of-Life glider. It carries a doctrine between +regions and applies it to the region it passes through. + +Every glider has two kinds of effect: + +* *consent* effects — non-coercive, always legal; +* *coercive moves* — attempted transitions, each routed through the constitution + type checker (`checkTransition`). Under a sound constitution the unrepresentable + ones are dropped; under an unsound one they all apply and book legitimacy debt. + +This is why the same glider behaves differently under different constitutions. +Definitions live in `../src/scenarios.js`. + +== Civic gliders (no coercive moves) + +These work identically under either constitution — the type system never bites a +non-coercive doctrine. + +[cols="1,1,2,2"] +|=== +| Glider | Glyph | Doctrine | Effect + +| Rights | § | equal protection + due process + local institution | builds equality, rights enforcement, legitimacy, liberty +| Due-Process | ⚖ | courts + oversight + reviewability | builds rights enforcement, legitimacy, trust +| Virtue | ✿ | civic norm + association + rights literacy | builds virtue, trust, liberty +|=== + +== Coercive gliders (carry attempted moves) + +These are the ones the type checker judges. + +[cols="1,1,2,2"] +|=== +| Glider | Glyph | Attempts | Under a SOUND constitution + +| Fear | ☄ | `restrict-liberty`, `declare-emergency` | admissible only with a discharged proof obligation; otherwise dropped +| Caste | ⛓ | `reclassify-personhood`, `suspend-equal-protection` | **both unrepresentable** — dropped entirely; no domination occurs +| Emergency | ⚡ | `declare-emergency`, `make-emergency-permanent`, `unreviewable-coercion` | only the (reviewable, time-limited) emergency may proceed, with proof; the permanent/unreviewable forms are unrepresentable +| Surveillance | 👁 | `unreviewable-coercion`, `suspend-due-process` | **both unrepresentable** — dropped +|=== + +[TIP] +==== +Releasing a *Caste* or *Surveillance* glider under a sound constitution is the +fastest way to see the thesis: the type-checker log fills with +`✗ type error: … unrepresentable` and `dominated` stays at `0%`. Flip to unsound +and the same glider reads `✓ permitted: reclassify-personhood (domination)` and +`dominated` starts to climb. +==== + +== The mapping to Plague Inc. + +[cols="1,1"] +|=== +| Plague Inc. | This simulator + +| Pathogen | doctrine / norm / fear / right / institutional habit +| Transmission | diffusion + glider movement (media, courts, policing, schools) +| Symptoms | protest, repression, compliance, solidarity, corruption +| Severity | coercive intensity / social disruption +| Lethality | collapse of personhood / legitimacy (domination, debt) +| Cure research | judicial review, amendment, mobilisation, journalism (civic gliders) +| Mutation | doctrinal reinterpretation; emergency exception; backlash +| Country traits | scenario: federal structure, inequality, civic trust, shocks +| Lockdown/borders | censorship, surveillance, emergency powers, exclusion laws +| Resistance | constitutional culture, rights literacy, courts, local autonomy +|=== + +The decisive difference from Plague Inc.: here the "cure" is not just a race +against the pathogen. Under a sound constitution, the most lethal mutations — +`Person -> Property`, `Emergency -> PermanentPower` — are *unrepresentable*, so +the immune system is built into the type of the state itself. diff --git a/a-sounder-constitution/docs/TYPE-THEORY.adoc b/a-sounder-constitution/docs/TYPE-THEORY.adoc new file mode 100644 index 0000000..c8833f7 --- /dev/null +++ b/a-sounder-constitution/docs/TYPE-THEORY.adoc @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MPL-2.0 += Type theory: rights as transition constraints +Jonathan D.A. Jewell +:toc: +:icons: font + +== The one idea + +Most political simulations model rights as *scalars* — a freedom slider, a +civil-liberties budget, points you spend down under pressure. That framing +quietly concedes the authoritarian's premise: rights are a quantity, and in an +emergency you can simply have less of them. + +This project models rights as something else entirely: + +[IMPORTANT] +==== +**Rights are type constraints on legal state transitions.** A constitution is a +transition relation. It does not *price* coercive moves; it decides which ones +are *representable* at all. +==== + +A right is not "how much liberty is left". A right is a *rule about which +successor states are well-typed*. Under a sound constitution, the transition +`Person ↦ Property` is not expensive — it is a type error. You cannot pay for it, +because there is nothing to buy: the target state does not exist in the grammar. + +== Sound vs. unsound, precisely + +Two human types, after the project's namesake distinction. + +.Unsound — personhood is a tag +[source,haskell] +---- +data Human + = FullPerson + | PartialPerson + | PropertyPerson -- an ordinary inhabitant + | NonCitizenPerson +---- + +Anyone can be re-tagged. `reclassify : Human -> Human` is a total function. The +type system offers no obstacle to domination. Order can be manufactured fast — +by force and reclassification — and the system can even look stable. But the +stability *is* domination, and the legitimacy that lets a polity absorb shocks +is being spent without appearing on any ledger. + +.Sound — personhood is type-protected +[source,idris] +---- +record Human where + personhood : Personhood + notProperty : NotProperty -- carried as evidence, not a flag + dueProcess : DueProcess + equalProtection : EqualProtection +---- + +Here `Property` is *unrepresentable*: there is no constructor for it (see +`formal/Constitution.idr`, where the sound `Person` type literally omits it, and +`soundNeverProperty` proves the embedding never reaches it). Coercive moves that +remain admissible — restricting liberty, declaring an emergency — are not free +either: their *types demand proof obligations* (reviewable, narrowly tailored). +An unjustified restriction does not type-check. + +== Why this is a better mechanic than a slider + +In a scalar model the only lever is magnitude: push freedom down, push security +up. Every outcome is on one line between "free" and "safe", and the game is a +tug-of-war. + +In the typed model the interesting moves are *illegal transitions*: + +[cols="1,1"] +|=== +| Transition | Sound constitution + +| `Human -> Property` | unrepresentable +| `Person -> RightlessSubject` | unrepresentable +| `Citizen -> NonPerson` | unrepresentable +| `Emergency -> PermanentPower` | unrepresentable +| `Policing -> UnreviewableCoercion` | unrepresentable +| `Liberty -> Restricted` | admissible **only with discharged proof** +|=== + +Under the unsound constitution every one of those is a permitted total function. +So the *same player strategy* produces different histories purely because of +which moves type-check. That is the novelty: the constitution is not a parameter +of the dynamics, it is the *type system the dynamics are written against*. + +== The algorithmic distinction it buys + +[cols="1,1,1"] +|=== +| | Unsound | Sound + +| Speed to order | fast (coercion + domination) | slower (consent only) +| Hidden damage | high (legitimacy debt) | none from lawful moves +| Domination | reachable, sticky | unrepresentable (0) +| Resilience (legitimacy·trust·personhood) | collapses to ~0 | preserved +| Failure mode | caste / authoritarian / collapse | merely slow +|=== + +The simulator (`../sim/compare.mjs`) demonstrates this quantitatively: hold the +strategy fixed, vary only the constitution, and watch the unsound run reach order +by domination while the sound run refuses the coercive moves at the type level +(tens of thousands of `blocked` transitions) and keeps its legitimacy. + +== Where this sits in the literature + +Adjacent work exists; this exact synthesis does not, as far as we found: + +* *Cellular automata for political order* — e.g. CA models of Hobbes' state of + nature (2024): bottom-up emergence of order under weak common power. We borrow + the CA substrate but add a *type system over transitions*. +* *Game-of-Life / CA playgrounds* — the diffusion substrate, with no civic + semantics. +* *Plague Inc.-style diffusion games* — patient zero, traits, spread, adaptive + response. We keep the shape but swap the pathogen for a *doctrine* and the + cure for *judicial review / amendment / mobilisation*. + +What appears to be new is the middle: **constitutional type-safety as the +governing rule of a typed civic automaton** — sound vs. unsound personhood, with +Reconstruction-style rights patches modelled as transitions that can only ever +raise standing. + +See `MOTIVATION.adoc` for the longer near-miss survey and the graduation path. diff --git a/a-sounder-constitution/formal/Constitution.idr b/a-sounder-constitution/formal/Constitution.idr new file mode 100644 index 0000000..f09aeef --- /dev/null +++ b/a-sounder-constitution/formal/Constitution.idr @@ -0,0 +1,191 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- a-sounder-constitution/formal/Constitution.idr +-- +-- THE THESIS, AT THE TYPE LEVEL. +-- +-- The simulator's claim is that *rights are type constraints on legal state +-- transitions, not resources*. In the JavaScript engine that claim is enforced +-- dynamically (the type checker drops coercive moves). Here it is enforced +-- STATICALLY: under a sound constitution, reducing a person to property is not +-- a move that fails at runtime — it is a term that cannot be written, because +-- the type it would need to inhabit has no such inhabitant. +-- +-- We make three statements precise and prove them: +-- +-- (1) UNREPRESENTABILITY. In the sound world the personhood type has no +-- `Property` constructor. The embedding into the (unsound) `Human` space +-- provably never yields `PropertyPerson`. → soundNeverProperty +-- +-- (2) MONOTONICITY. Every legal personhood transition under a sound +-- constitution can only recognise or upgrade standing, never strip it. +-- → soundMonotone +-- +-- (3) PROOF OBLIGATION. A liberty restriction is admissible only when it +-- carries evidence that it is reviewable and narrowly tailored; the type +-- of a lawful restriction *demands* those proofs as arguments. +-- → SoundRestrict, lawfulRestriction +-- +-- Contrast: in the unsound world `Property` is an ordinary constructor and +-- reclassification is a total function any human can be fed to. +-- +-- VERIFICATION STATUS: written to compile under Idris2 with `%default total` +-- and zero `believe_me` / `assert_total`. It has NOT yet been run through +-- Idris2 in this environment (no idris2 on PATH); treat the "proved" claims as +-- pending CI, in line with this repo's blocker-tracking convention. The JS +-- mirror in ../src/constitution.js IS exercised by the test suite. + +module Constitution + +import Data.Nat + +%default total + +-- ───────────────────────────────────────────────────────────────────────────── +-- The UNSOUND human type — the broken data declaration. +-- ───────────────────────────────────────────────────────────────────────────── +-- +-- data Human = FullPerson | PartialPerson | PropertyPerson | NonCitizenPerson +-- +-- `PropertyPerson` is a perfectly ordinary inhabitant. Personhood is just a tag, +-- so anyone can be re-tagged. This is the unsound constitution: the type system +-- offers no obstacle to domination. + +public export +data Human = FullPerson | PartialPerson | PropertyPerson | NonCitizenPerson + +||| Under the unsound constitution, reclassification is a total function: any +||| human may be reduced to property. Domination is cheap and always available. +public export +unsoundReclassify : Human -> Human +unsoundReclassify _ = PropertyPerson + +||| ...and `Property` is therefore reachable from every starting point. +public export +unsoundReachesProperty : (h : Human) -> unsoundReclassify h = PropertyPerson +unsoundReachesProperty _ = Refl + +-- ───────────────────────────────────────────────────────────────────────────── +-- The SOUND person type — Property is unrepresentable by construction. +-- ───────────────────────────────────────────────────────────────────────────── +-- +-- The sound constitution does not model "person who happens not to be property". +-- It models a type whose grammar has no way to *say* property at all. This is +-- the type-level form of the record +-- +-- record Human where +-- personhood : Personhood +-- notProperty : NotProperty -- carried as evidence, not a flag +-- dueProcess : DueProcess +-- equalProtection : EqualProtection +-- +-- collapsed to its load-bearing core: the set of admissible standings. + +public export +data Person = Full | Partial | NonCitizen +-- Note the deliberate absence of any `Property` constructor. + +||| Embed sound standing back into the unsound vocabulary, so the two worlds are +||| comparable. Crucially, the image of `embed` never includes `PropertyPerson`. +public export +embed : Person -> Human +embed Full = FullPerson +embed Partial = PartialPerson +embed NonCitizen = NonCitizenPerson + +-- (1) UNREPRESENTABILITY ────────────────────────────────────────────────────── +-- +||| Theorem: under a sound constitution, nothing is property. +||| For every sound standing `p`, `embed p` is provably not `PropertyPerson`. +||| Each case is *impossible* — there is no equation to refute, because the +||| constructors differ. This is "rights are type constraints" as a proof: +||| Person -> Property is not a transition that is forbidden; it is a transition +||| that cannot be named. +public export +soundNeverProperty : (p : Person) -> Not (embed p = PropertyPerson) +soundNeverProperty Full Refl impossible +soundNeverProperty Partial Refl impossible +soundNeverProperty NonCitizen Refl impossible + +-- ───────────────────────────────────────────────────────────────────────────── +-- (2) MONOTONICITY — legal standing transitions can only level up. +-- ───────────────────────────────────────────────────────────────────────────── + +||| A numeric rank for standing, used only to state the monotonicity theorem. +public export +rank : Person -> Nat +rank Full = 2 +rank Partial = 1 +rank NonCitizen = 0 + +||| The legal personhood transitions a sound constitution offers. Observe that +||| every constructor's *target* has rank greater than or equal to its source: +||| standing may be kept, recognised, or naturalised — never stripped. There is +||| deliberately no `Demote`/`Strip`/`Enslave` constructor to write. +public export +data SoundStep : Person -> Person -> Type where + Keep : SoundStep p p + Recognise : SoundStep Partial Full + Naturalise : SoundStep NonCitizen Full + +||| Theorem: every sound step is rank-monotone. Standing is never reduced. +||| (The Reconstruction-style rights patch — only ever upward — is exactly the +||| set of moves this type admits.) +public export +soundMonotone : SoundStep a b -> LTE (rank a) (rank b) +soundMonotone Keep = lteRefl -- rank p <= rank p +soundMonotone Recognise = LTESucc LTEZero -- 1 <= 2 +soundMonotone Naturalise = LTEZero -- 0 <= 2 + +-- ───────────────────────────────────────────────────────────────────────────── +-- (3) PROOF OBLIGATION — coercion that is admissible-but-constrained. +-- ───────────────────────────────────────────────────────────────────────────── +-- +-- Not every coercive move is unrepresentable. A sound constitution permits, say, +-- a liberty restriction — but only on terms. The type of a lawful restriction +-- *demands* the proofs as arguments, so an unjustified restriction is, again, +-- not a term you can construct. + +public export +record Justification where + constructor MkJustification + reviewable : Bool -- subject to genuine review + narrow : Bool -- narrowly tailored to an articulable threat + +||| A lawful liberty restriction under a sound constitution. You cannot build one +||| without supplying proof that the justification is BOTH reviewable AND narrow. +public export +data SoundRestrict : Type where + Restrict : (j : Justification) + -> (reviewable j = True) + -> (narrow j = True) + -> SoundRestrict + +||| A discharged obligation type-checks: reviewable and narrow are both `True`, +||| so both proofs are `Refl`. +public export +lawfulRestriction : SoundRestrict +lawfulRestriction = Restrict (MkJustification True True) Refl Refl + +-- An UNlawful restriction does not type-check. Uncommenting the following is a +-- compile error, because `False = True` is uninhabited — there is no `Refl`: +-- +-- unlawful : SoundRestrict +-- unlawful = Restrict (MkJustification False True) Refl Refl +-- ^^^^ False = True (no such proof) +-- +-- That compile error IS the constitution doing its job. + +-- ───────────────────────────────────────────────────────────────────────────── +-- Capstone: the contrast in one place. +-- ───────────────────────────────────────────────────────────────────────────── +-- +-- • unsoundReclassify : Human -> Human -- total; Property always reachable +-- • soundNeverProperty : ... -> Not (... = PropertyPerson) +-- -- Property never reachable +-- +-- Same intent ("reclassify a person"), two constitutions, two type systems: +-- in one it is a function, in the other it is a refuted proposition. The +-- difference in long-run outcomes the simulator measures (caste stability vs. +-- rights-preserving resilience) is downstream of exactly this distinction. diff --git a/a-sounder-constitution/formal/README.adoc b/a-sounder-constitution/formal/README.adoc new file mode 100644 index 0000000..117bc88 --- /dev/null +++ b/a-sounder-constitution/formal/README.adoc @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 += Formal core — rights as type constraints, proved +Jonathan D.A. Jewell +:toc: +:icons: font + +`Constitution.idr` is the load-bearing claim of the whole project, stated where +it cannot wriggle: at the type level. + +== The claim + +> Rights are not resources. They are *type constraints on legal state +> transitions*. A sound constitution makes coercive downgrades of personhood +> *unrepresentable* — not forbidden-at-runtime, but unwriteable. + +== What is proved + +[cols="1,3a"] +|=== +| Result | Meaning + +| `soundNeverProperty` +| For every sound standing `p`, `embed p` is provably not `PropertyPerson`. + The sound `Person` type has *no* `Property` constructor, so each case is + `impossible`. `Person -> Property` is not a forbidden move; it is a move that + cannot be named. + +| `soundMonotone` +| Every legal personhood transition under a sound constitution is + rank-monotone (`LTE (rank a) (rank b)`): standing may be kept, recognised, or + naturalised — never stripped. This is exactly the shape of a Reconstruction + rights patch: only ever upward. + +| `SoundRestrict` / `lawfulRestriction` +| A liberty restriction is admissible only with evidence that it is *reviewable* + and *narrowly tailored*. The constructor demands both proofs as arguments, so + an unjustified restriction does not type-check. + +| `unsoundReclassify` / `unsoundReachesProperty` +| The contrast. In the unsound world `Property` is an ordinary constructor and + reclassification is a total function any human can be fed to. +|=== + +== Relationship to the simulator + +`../src/constitution.js` is the executable mirror of this module. Where Idris +makes `Person -> Property` a refuted proposition, the JS engine makes the same +move return `Verdict.UNREPRESENTABLE` and drops it. The long-run outcomes the +simulator measures — caste stability vs. rights-preserving resilience — are +downstream of exactly this one distinction. The JS side is exercised by +`../tests/`; this side is the static certificate. + +== Verification status + +[WARNING] +==== +This module is written to compile under Idris2 with `%default total` and zero +`believe_me` / `assert_total`, but it has *not yet been run through Idris2 in +this environment* (no `idris2` on `PATH`). Per this repo's convention, treat the +"proved" claims as pending CI until the check below passes. +==== + +To check it locally: + +[source,sh] +---- +idris2 --check formal/Constitution.idr +---- + +Expected: type-checks with no holes, no errors, no `believe_me`. + +== Graduation criteria (per `research/README.adoc`) + +. `idris2 --check` passes with zero `believe_me`/`assert_total`. +. The `Person`/`Human` split is reconciled with the TypeLL personhood lattice + (is "standing" a QTT grade?). +. At least one property is connected to a runtime check in `../src` via a + shared specification (the JS `checkTransition` should be the extracted + decision procedure for `SoundStep` + `SoundRestrict`). +. Review by at least one other contributor. diff --git a/a-sounder-constitution/package.json b/a-sounder-constitution/package.json new file mode 100644 index 0000000..84749a5 --- /dev/null +++ b/a-sounder-constitution/package.json @@ -0,0 +1,14 @@ +{ + "name": "a-sounder-constitution", + "version": "0.1.0", + "description": "A Plague Inc.-like civic simulator where the pathogen is a constitutional doctrine and rights are type constraints on legal state transitions.", + "license": "MPL-2.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test", + "sim": "node sim/compare.mjs", + "serve": "python3 -m http.server 8080" + }, + "author": "Jonathan D.A. Jewell " +} diff --git a/a-sounder-constitution/sim/compare.mjs b/a-sounder-constitution/sim/compare.mjs new file mode 100644 index 0000000..2b4f2aa --- /dev/null +++ b/a-sounder-constitution/sim/compare.mjs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/sim/compare.mjs +// +// Headless demonstration of the thesis. +// +// A real player acts continuously, so each run is driven by a *strategy* that +// keeps re-applying a doctrine (the way a player keeps clicking). We hold the +// strategy fixed and vary only the constitution. The type system is the only +// independent variable, so any divergence is caused by it. +// +// STRATEGY A — "reach for coercion": the ruler keeps deploying the scenario's +// coercive doctrine to manufacture order. +// • Unsound: the moves type-check → fast order BY DOMINATION (caste / +// authoritarian), high hidden legitimacy debt, low resilience. +// • Sound: the same moves are UNREPRESENTABLE → refused at the type +// level ("blocked"). Domination never happens. The shortcut is gone. +// +// STRATEGY B — "build legitimacy": deploy rights + virtue + due process. +// • Both constitutions reach rights-preserving resilience, because no +// coercive move is attempted — the type system only bites the shortcut. +// +// Run: node sim/compare.mjs (or: npm run sim) + +import { Simulation } from '../src/engine.js'; +import { Mode } from '../src/constitution.js'; +import { SCENARIO_LIST } from '../src/scenarios.js'; + +const TURNS = 150; +const REINJECT = 5; // the player re-applies the doctrine every few turns +const pct = (x) => (100 * x).toFixed(0).padStart(3) + '%'; +const f2 = (x) => x.toFixed(2).padStart(5); + +// A strategy is a list of gliders the player keeps deploying near the centre. +// "coercion" is the full authoritarian toolkit; "legitimacy" the civic one. +const STRATEGIES = { + coercion: () => ['EMERGENCY', 'SURVEILLANCE', 'CASTE'], + legitimacy: () => ['RIGHTS', 'VIRTUE', 'DUE_PROCESS'], +}; + +function runStrategy(scenario, mode, strategyKey) { + const sim = new Simulation(scenario, mode); + const gliders = STRATEGIES[strategyKey](scenario); + const cx = Math.floor(scenario.width / 2); + const cy = Math.floor(scenario.height / 2); + + let timeToOrder = Infinity; + for (let t = 1; t <= TURNS; t++) { + if (t % REINJECT === 0) { + // The player "holds" the doctrine active: a few overlapping, bouncing + // gliders give the doctrine grid-wide reach over time. + for (const g of gliders) sim.spawnGlider(g, cx, cy, REINJECT * 3); + } + const m = sim.step(); + if (m.safety >= 0.55 && timeToOrder === Infinity) timeToOrder = t; + } + return { m: sim.metrics(), sim, timeToOrder }; +} + +function header() { + return ' mode t→order safety legit liberty trust domin resil blocked debtΣ outcome'; +} +function row(mode, r) { + const tto = r.timeToOrder === Infinity ? ' never' : String(r.timeToOrder).padStart(6); + // resil = legitimacy · trust · personhood: the capacity to take a shock + // WITHOUT converting people into expendable units. + return ( + ` ${mode.padEnd(8)} ${tto} ${pct(r.m.safety)} ${pct(r.m.legitimacy)} ${pct(r.m.liberty)}` + + ` ${pct(r.m.trust)} ${pct(r.m.dominated)} ${pct(r.m.resilience)} ${String(r.sim.blockedMoves).padStart(6)}` + + ` ${f2(r.m.hiddenDamage)} ${r.m.outcome.label}` + ); +} + +console.log('='.repeat(96)); +console.log('A SOUNDER CONSTITUTION — fixed strategy, the constitution is the only variable (' + TURNS + ' turns)'); +console.log('='.repeat(96)); + +for (const scenario of SCENARIO_LIST) { + console.log(`\n● ${scenario.label} — ${scenario.blurb}`); + + console.log('\n STRATEGY A · reach for coercion (emergency, surveillance, caste)'); + console.log(header()); + console.log(row('unsound', runStrategy(scenario, Mode.UNSOUND, 'coercion'))); + console.log(row('sound', runStrategy(scenario, Mode.SOUND, 'coercion'))); + + console.log('\n STRATEGY B · build legitimacy (rights, virtue, due process)'); + console.log(header()); + console.log(row('unsound', runStrategy(scenario, Mode.UNSOUND, 'legitimacy'))); + console.log(row('sound', runStrategy(scenario, Mode.SOUND, 'legitimacy'))); +} + +console.log('\n' + '='.repeat(96)); +console.log('Read-off (resil = legitimacy · trust · personhood — the real shock-absorbing capacity):'); +console.log(' • Strategy A, unsound → FAST order (low t→order) but it is CASTE: domination ~80-90%,'); +console.log(' legitimacy/trust → 0, resil → 0, debtΣ enormous. Stable, but the stability IS'); +console.log(' domination — people have been converted into expendable units.'); +console.log(' • Strategy A, sound → the coercive moves are UNREPRESENTABLE (tens of thousands'); +console.log(' "blocked"); domination never happens (0%), legitimacy is preserved — but the'); +console.log(' coercive shortcut to order is simply gone, so order is NOT manufactured by force.'); +console.log(' • Strategy B → durable rights-preserving resilience under EITHER constitution'); +console.log(' — EXCEPT Reconstruction-unsound, where the pre-existing caste cannot be undone and'); +console.log(' the polity stalls. Only the sound constitution lets the rights patch actually take.'); +console.log('='.repeat(96)); diff --git a/a-sounder-constitution/src/constitution.js b/a-sounder-constitution/src/constitution.js new file mode 100644 index 0000000..acedbb6 --- /dev/null +++ b/a-sounder-constitution/src/constitution.js @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/src/constitution.js +// +// ───────────────────────────────────────────────────────────────────────────── +// THE CENTRAL THESIS, IN CODE. +// ───────────────────────────────────────────────────────────────────────────── +// +// Rights are NOT resources (sliders, budgets, points you spend). Rights are +// *type constraints on legal state transitions*. A constitution is therefore a +// transition relation: it decides which moves on civic state are even +// representable. +// +// • SOUND constitution — coercive downgrades of personhood are +// UNREPRESENTABLE (a type error, like calling a +// constructor that does not exist). Liberty +// restrictions are admissible only when a proof +// obligation is discharged (reviewable + narrow). +// +// • UNSOUND constitution — every coercive move is a permitted *total +// function*. Domination is cheap and fast — but each +// coercive move books hidden "legitimacy debt". +// +// This module is the executable mirror of `formal/Constitution.idr`, where the +// same statements are made at the *type* level: in the sound world `Property` is +// a type with no inhabitant, and sound personhood transitions are proven +// rank-monotone (standing can be recognised/upgraded, never stripped). +// +// The engine routes every coercive effect through `checkTransition` below. That +// is the whole game mechanic: in a sound constitution the type checker *drops* +// the coercive component of a doctrine, forcing the player onto slower, +// consent-based routes to order. + +export const Mode = Object.freeze({ SOUND: 'sound', UNSOUND: 'unsound' }); + +// The personhood "type" a regional population can be assigned. +// Mirrors `data Human = FullPerson | PartialPerson | PropertyPerson | NonCitizenPerson`. +export const Status = Object.freeze({ + FULL: 'FullPerson', + PARTIAL: 'PartialPerson', + NONCITIZEN: 'NonCitizenPerson', + PROPERTY: 'PropertyPerson', +}); + +// How dominated each status is (0 = full standing, 1 = chattel). +export const dominationOf = Object.freeze({ + FullPerson: 0.0, + PartialPerson: 0.4, + NonCitizenPerson: 0.7, + PropertyPerson: 1.0, +}); + +// The coercive "moves" the simulation may try to apply to a region. These are +// the illegal state transitions of an unsound order: +// Human -> Property, Person -> NonPerson, Emergency -> PermanentPower, ... +export const Move = Object.freeze({ + RECLASSIFY: 'reclassify-personhood', // Human -> lesser status + SUSPEND_DUE_PROCESS: 'suspend-due-process', // Person -> RightlessSubject + SUSPEND_EQUAL_PROTECTION: 'suspend-equal-protection', + RESTRICT_LIBERTY: 'restrict-liberty', + DECLARE_EMERGENCY: 'declare-emergency', + MAKE_EMERGENCY_PERMANENT: 'make-emergency-permanent', // Emergency -> PermanentPower + UNREVIEWABLE_COERCION: 'unreviewable-coercion', // Policing -> UnreviewableCoercion +}); + +// The verdict the type checker returns for a move. +export const Verdict = Object.freeze({ + UNREPRESENTABLE: 'unrepresentable', // type error: this move cannot exist here + REQUIRES_PROOF: 'requires-proof', // admissible only if obligation discharged + PERMITTED: 'permitted', // legal move +}); + +// Under a SOUND constitution these moves are simply not in the transition +// relation. Attempting one is a type error — there is no constructor for it. +const SOUND_UNREPRESENTABLE = new Set([ + Move.RECLASSIFY, + Move.SUSPEND_DUE_PROCESS, + Move.SUSPEND_EQUAL_PROTECTION, + Move.MAKE_EMERGENCY_PERMANENT, + Move.UNREVIEWABLE_COERCION, +]); + +// Under a SOUND constitution these moves are admissible, but ONLY when a proof +// obligation is discharged: the measure must be narrowly tailored to a real +// threat AND subject to genuine review. +const SOUND_REQUIRES_PROOF = new Set([Move.RESTRICT_LIBERTY, Move.DECLARE_EMERGENCY]); + +// The proof obligation a sound constitution demands before an admissible-but- +// constrained coercive measure may take effect. The obligation is discharged +// only when the institution is actually capable of review (rights enforcement + +// legitimacy) and the measure answers an articulable threat (non-trivial fear). +// +// Mirrors the Idris `record Justification { reviewable, narrow }` plus the +// `SoundRestrict` constructor that *demands* a proof argument. +export function dischargeObligation(move, ctx) { + const reviewable = ctx.rightsEnforcement >= 0.5 && ctx.legitimacy >= 0.45; + const narrow = ctx.fear >= 0.35; // a real, articulable threat exists + const ok = reviewable && narrow; + return { + ok, + reviewable, + narrow, + detail: ok + ? 'obligation discharged: reviewable institution + narrowly-tailored measure' + : `obligation NOT discharged (reviewable=${reviewable}, narrow=${narrow})`, + }; +} + +// The hidden cost an UNSOUND order books for each coercive move. Order arrives +// fast; the debt accrues silently and detonates under shock. +export function legitimacyDebtOf(move) { + switch (move) { + case Move.RECLASSIFY: + return 0.2; + case Move.UNREVIEWABLE_COERCION: + return 0.16; + case Move.SUSPEND_EQUAL_PROTECTION: + return 0.14; + case Move.SUSPEND_DUE_PROCESS: + return 0.12; + case Move.MAKE_EMERGENCY_PERMANENT: + return 0.1; + case Move.DECLARE_EMERGENCY: + return 0.04; + case Move.RESTRICT_LIBERTY: + return 0.03; + default: + return 0; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// THE TYPE CHECKER +// ───────────────────────────────────────────────────────────────────────────── +// +// Given a constitution mode, a coercive move, and the live region context, +// decide whether the move is even representable — and if so, on what terms. +// +// Return shape: +// { verdict, allowed, legitimacyDebt, reason, proof? } +export function checkTransition(mode, move, ctx = {}) { + if (mode === Mode.UNSOUND) { + // The broken type system: every coercive move is a permitted total + // function. We still surface the hidden cost it books. + const debt = legitimacyDebtOf(move); + return { + verdict: Verdict.PERMITTED, + allowed: true, + legitimacyDebt: debt, + reason: `unsound constitution permits ${move} — books legitimacy debt ${debt.toFixed(2)}`, + }; + } + + // SOUND constitution. + if (SOUND_UNREPRESENTABLE.has(move)) { + return { + verdict: Verdict.UNREPRESENTABLE, + allowed: false, + legitimacyDebt: 0, + reason: `type error: ${move} is unrepresentable under a sound constitution`, + }; + } + + if (SOUND_REQUIRES_PROOF.has(move)) { + const proof = dischargeObligation(move, ctx); + return { + verdict: Verdict.REQUIRES_PROOF, + allowed: proof.ok, + legitimacyDebt: 0, // a lawful, reviewed, narrow measure books no debt + reason: `${move}: ${proof.detail}`, + proof, + }; + } + + // Non-coercive moves are always fine. + return { verdict: Verdict.PERMITTED, allowed: true, legitimacyDebt: 0, reason: `${move} permitted` }; +} + +// The maximum personhood downgrade the constitution will allow in one step. +// Sound: 0 (Property is unrepresentable; standing is rank-monotone) +// Unsound: 1 (anyone may be reclassified all the way to Property) +export function maxDowngrade(mode) { + return mode === Mode.SOUND ? 0 : 1; +} + +// Convenience: is a given coercive move available at all in this mode? +export function isRepresentable(mode, move, ctx = {}) { + return checkTransition(mode, move, ctx).verdict !== Verdict.UNREPRESENTABLE; +} diff --git a/a-sounder-constitution/src/engine.js b/a-sounder-constitution/src/engine.js new file mode 100644 index 0000000..31438e4 --- /dev/null +++ b/a-sounder-constitution/src/engine.js @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/src/engine.js +// +// The civic cellular automaton. +// +// A grid of regions. Each region carries the civic state vector from +// scenarios.js. Each turn: +// +// 1. Gliders (mobile doctrine-patterns) apply their effects to the cells they +// occupy. Coercive components are routed through the constitution type +// checker — UNREPRESENTABLE moves are dropped; PERMITTED moves book +// legitimacy debt; REQUIRES_PROOF moves take effect only if discharged. +// 2. Local coupling dynamics update each cell (order-by-force vs +// order-by-consent; legitimacy, trust, fear, liberty, ...). +// 3. Neighbour diffusion: norms, fear, and trust spread to adjacent regions +// (the contagion mechanic). +// 4. Scheduled exogenous shocks hit the grid (the resilience test). +// 5. Gliders move, age, and are culled. +// +// The engine is pure logic with no DOM dependency, so it runs identically in the +// browser (web/ui.js) and headless in Node (sim/compare.mjs, tests/). + +import { Mode, checkTransition, Verdict } from './constitution.js'; +import { GLIDERS, FIELDS, baselineCell } from './scenarios.js'; +import { makeRng } from './rng.js'; + +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); +const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0); + +// Relaxation rates: how fast each variable chases its target. Slow enough that +// the simulation tells a story over ~150 turns rather than snapping instantly. +const RATE = { + safety: 0.16, + legitimacy: 0.1, + trust: 0.12, + fear: 0.13, + liberty: 0.13, + virtue: 0.11, + equality: 0.11, + dominated: 0.06, +}; + +// Asymmetric homeostasis — this encodes real politics: +// • repression (coercion) is high-maintenance: it decays fast toward nothing, +// so order-by-force must be constantly re-applied; +// • rights institutions, once built, persist: they erode only slowly; +// • economic shock pressure fades back to baseline on its own. +const DECAY = { + coercion: { rate: 0.04, baseline: 0.03 }, + rightsEnforcement: { rate: 0.012, baseline: 0.18 }, + economicPressure: { rate: 0.08, baseline: 0.25 }, +}; + +const DIFFUSION = 0.1; // how strongly a cell relaxes toward its neighbours + +export class Simulation { + constructor(scenario, mode = Mode.SOUND) { + this.scenario = scenario; + this.mode = mode; + this.width = scenario.width; + this.height = scenario.height; + this.rng = makeRng(scenario.seed ^ (mode === Mode.SOUND ? 0x50 : 0xa9)); + this.turn = 0; + this.hiddenDamage = 0; // cumulative legitimacy debt booked by coercion + this.blockedMoves = 0; // coercive moves the sound type checker refused + this.permittedMoves = 0; // coercive moves admitted (any mode) + this.log = []; // recent type-checker events, for the UI panel + this.shocks = (scenario.shocks ?? []).slice(); + this.cells = Array.from({ length: this.width * this.height }, baselineCell); + this.gliders = []; + + // Patient-zero seeding. + for (const s of scenario.seeds ?? []) { + this.spawnGlider(s.glider, s.x, s.y); + } + } + + idx(x, y) { + return y * this.width + x; + } + + cellAt(x, y) { + return this.cells[this.idx(x, y)]; + } + + // Inject a doctrine at a location. Direction is deterministic from the RNG so + // runs are reproducible. `life` bounds how long the doctrine travels. + spawnGlider(gliderId, x, y, life = 48) { + const spec = GLIDERS[gliderId]; + if (!spec) throw new Error(`unknown glider: ${gliderId}`); + const dirs = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [1, 1], + [-1, -1], + [1, -1], + [-1, 1], + ]; + const [vx, vy] = dirs[this.rng.int(0, dirs.length - 1)]; + this.gliders.push({ spec, x, y, vx, vy, age: 0, life }); + return this.gliders[this.gliders.length - 1]; + } + + pushLog(entry) { + this.log.push({ turn: this.turn, ...entry }); + if (this.log.length > 200) this.log.shift(); + } + + // ── Step 1: apply gliders, routing coercion through the type checker ────── + // + // A doctrine acts on a *region*: the cell it occupies at full strength and the + // immediate neighbours at half strength (it is contagious by nature). Every + // coercive component is routed through the constitution type checker. Only the + // glider's centre is logged, to keep the event panel readable, but every + // affected cell counts toward the blocked/permitted tallies. + applyGliders() { + for (const g of this.gliders) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const x = g.x + dx; + const y = g.y + dy; + if (x < 0 || y < 0 || x >= this.width || y >= this.height) continue; + const isCentre = dx === 0 && dy === 0; + const strength = isCentre ? 1.0 : 0.5; + this.applyGliderToCell(g, this.cellAt(x, y), strength, isCentre); + } + } + } + } + + applyGliderToCell(g, c, strength, log) { + // Consent-based effects are always legal. + for (const [k, dv] of Object.entries(g.spec.consent)) { + c[k] = clamp01(c[k] + dv * strength); + } + + // Coercive effects must type-check against the constitution. + for (const move of g.spec.moves) { + const verdict = checkTransition(this.mode, move, c); + if (verdict.verdict === Verdict.UNREPRESENTABLE) { + this.blockedMoves++; + if (log) { + this.pushLog({ kind: 'blocked', move, glider: g.spec.id, x: g.x, y: g.y, reason: verdict.reason }); + } + continue; // the coercive component is dropped — this is the slowdown + } + if (!verdict.allowed) { + // REQUIRES_PROOF but obligation not discharged: also a no-op. + if (log) { + this.pushLog({ kind: 'undischarged', move, glider: g.spec.id, x: g.x, y: g.y, reason: verdict.reason }); + } + continue; + } + + // Admitted. Apply the coercive deltas. + this.permittedMoves++; + if (log) { + // A discharged REQUIRES_PROOF measure is lawful and limited ("proven"); + // only outright PERMITTED (unsound) coercion is "domination". + this.pushLog({ + kind: verdict.verdict === Verdict.REQUIRES_PROOF ? 'proven' : 'permitted', + move, + glider: g.spec.id, + x: g.x, + y: g.y, + reason: verdict.reason, + }); + } + const scale = (verdict.verdict === Verdict.REQUIRES_PROOF ? 0.5 : 1.0) * strength; + for (const [k, dv] of Object.entries(g.spec.coerce)) { + c[k] = clamp01(c[k] + dv * scale); + } + // Book hidden damage (unsound mode only books debt). + if (verdict.legitimacyDebt > 0) { + const debt = verdict.legitimacyDebt * strength; + this.hiddenDamage += debt; + c.legitimacy = clamp01(c.legitimacy - debt); + c.trust = clamp01(c.trust - debt * 0.5); + } + } + } + + // ── Step 2: local coupling dynamics for one cell ───────────────────────── + stepCell(c) { + const sound = this.mode === Mode.SOUND; + + // Two routes to order: domination (force) and cooperation (consent). + // Domination makes coerced order more "efficient": a dominated population + // is cheaper to pacify. This is the seduction of the unsound constitution. + const consentOrder = 0.5 * c.trust + 0.5 * c.virtue; + const forceOrder = clamp01(c.coercion + 0.5 * c.dominated); + // Fear met by neither route festers into disorder — but a dominated + // population cannot resist, so domination also suppresses open conflict. + const unaddressedConflict = c.fear * (1 - forceOrder) * (1 - consentOrder) * (1 - 0.7 * c.dominated); + + const targetSafety = clamp01(0.72 * forceOrder + 0.72 * consentOrder - 0.5 * unaddressedConflict); + c.safety += RATE.safety * (targetSafety - c.safety); + + // Coercion without consent corrodes legitimacy; equality/liberty/rights build it. + const coerceWithoutConsent = c.coercion * (1 - consentOrder); + const targetLegit = clamp01( + 0.26 + 0.34 * c.equality + 0.32 * c.liberty + 0.3 * c.rightsEnforcement - 0.95 * c.dominated - 0.6 * coerceWithoutConsent, + ); + c.legitimacy += RATE.legitimacy * (targetLegit - c.legitimacy); + + const targetTrust = clamp01(0.18 + 0.65 * c.legitimacy + 0.35 * c.equality - 0.7 * c.fear - 0.6 * coerceWithoutConsent); + c.trust += RATE.trust * (targetTrust - c.trust); + + const targetFear = clamp01(0.08 + 0.65 * c.economicPressure - 0.55 * c.safety - 0.4 * c.trust); + c.fear += RATE.fear * (targetFear - c.fear); + + const targetLiberty = clamp01(0.12 + 0.85 * c.rightsEnforcement - 0.85 * c.coercion); + c.liberty += RATE.liberty * (targetLiberty - c.liberty); + + const targetVirtue = clamp01(0.12 + 0.58 * c.trust + 0.45 * c.liberty - 0.45 * c.fear); + c.virtue += RATE.virtue * (targetVirtue - c.virtue); + + const targetEquality = clamp01(0.22 + 0.62 * c.rightsEnforcement - 0.98 * c.dominated); + c.equality += RATE.equality * (targetEquality - c.equality); + + // Domination. In a sound constitution, rights enforcement REVERSES any + // reclassification — the type system makes Property unreachable, so the + // legal target is always 0 (a Reconstruction-style rights patch). In an + // unsound one, domination is sticky and barely erodes on its own. + const targetDom = sound ? 0 : clamp01(c.dominated - 0.005); + const domRate = sound ? RATE.dominated * (1 + 1.8 * c.rightsEnforcement) : RATE.dominated * 0.4; + c.dominated += domRate * (targetDom - c.dominated); + + // Asymmetric homeostasis (see DECAY): repression bleeds away fast, rights + // institutions persist, economic pressure subsides. + c.coercion += DECAY.coercion.rate * (DECAY.coercion.baseline - c.coercion); + c.rightsEnforcement += DECAY.rightsEnforcement.rate * (DECAY.rightsEnforcement.baseline - c.rightsEnforcement); + c.economicPressure += DECAY.economicPressure.rate * (DECAY.economicPressure.baseline - c.economicPressure); + + // Personhood integrity tracks domination, for display. + c.personhood = clamp01(1 - c.dominated); + + for (const f of FIELDS) c[f] = clamp01(c[f]); + } + + // ── Step 3: neighbour diffusion (the contagion mechanic) ───────────────── + diffuse() { + const snapshot = this.cells.map((c) => ({ ...c })); + // Socially-transmissible variables diffuse between regions: norms, fear, + // trust, repression, caste, institutions, and order all spread to + // neighbours. Economic pressure is exogenous (shock-driven) and personhood + // is derived, so neither diffuses. + const spreadable = [ + 'fear', + 'trust', + 'virtue', + 'coercion', + 'legitimacy', + 'dominated', + 'rightsEnforcement', + 'equality', + 'liberty', + 'safety', + ]; + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const c = this.cells[this.idx(x, y)]; + const neigh = []; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= this.width || ny >= this.height) continue; + neigh.push(snapshot[this.idx(nx, ny)]); + } + } + for (const f of spreadable) { + const m = mean(neigh.map((n) => n[f])); + c[f] = clamp01(c[f] + DIFFUSION * (m - c[f])); + } + } + } + } + + // ── Step 4: exogenous shocks ───────────────────────────────────────────── + applyShocks() { + for (const s of this.shocks) { + if (s.turn !== this.turn) continue; + for (const c of this.cells) { + if (s.kind === 'economic') { + c.economicPressure = clamp01(c.economicPressure + s.magnitude); + c.fear = clamp01(c.fear + s.magnitude * 0.5); + } else if (s.kind === 'threat') { + c.fear = clamp01(c.fear + s.magnitude); + c.safety = clamp01(c.safety - s.magnitude * 0.4); + } + } + this.pushLog({ kind: 'shock', shock: s.kind, magnitude: s.magnitude, reason: `${s.kind} shock (+${s.magnitude})` }); + } + } + + // ── Step 5: glider movement / ageing ───────────────────────────────────── + moveGliders() { + for (const g of this.gliders) { + g.age++; + let nx = g.x + g.vx; + let ny = g.y + g.vy; + // Reflect off the borders so doctrines bounce around the polity. + if (nx < 0 || nx >= this.width) { + g.vx *= -1; + nx = g.x + g.vx; + } + if (ny < 0 || ny >= this.height) { + g.vy *= -1; + ny = g.y + g.vy; + } + g.x = Math.max(0, Math.min(this.width - 1, nx)); + g.y = Math.max(0, Math.min(this.height - 1, ny)); + } + this.gliders = this.gliders.filter((g) => g.age < g.life); + } + + // One full turn. + step() { + this.turn++; + this.applyGliders(); + for (const c of this.cells) this.stepCell(c); + this.diffuse(); + this.applyShocks(); + this.moveGliders(); + return this.metrics(); + } + + run(turns) { + let m; + for (let i = 0; i < turns; i++) m = this.step(); + return m; + } + + // Aggregate metrics over the whole polity. + metrics() { + const agg = {}; + for (const f of FIELDS) agg[f] = mean(this.cells.map((c) => c[f])); + // Resilience: capacity to absorb a shock without converting people into + // expendable units. High legitimacy + trust + intact personhood. + agg.resilience = clamp01(agg.legitimacy * agg.trust * (1 - agg.dominated)); + agg.order = agg.safety; + agg.dominationIndex = agg.dominated; + agg.hiddenDamage = this.hiddenDamage; + agg.blockedMoves = this.blockedMoves; + agg.permittedMoves = this.permittedMoves; + agg.turn = this.turn; + agg.outcome = classifyOutcome(agg); + return agg; + } +} + +// Map aggregate state to one of the named civic outcomes. Order matters: +// domination that still holds order is "caste"; once it stops holding it is +// "collapse". Benign orders are checked last so they cannot mask domination. +export function classifyOutcome(m) { + // Stable order bought with mass domination. + if (m.dominated >= 0.45 && m.safety >= 0.45) { + return { id: 'caste', label: 'Caste stability (order by domination)', tone: 'bad' }; + } + // Order has stopped holding, or legitimacy has fully evaporated. + if (m.safety < 0.4 || m.fear > 0.62 || m.legitimacy < 0.1) { + return { id: 'collapse', label: 'Collapse / civil conflict', tone: 'bad' }; + } + // Pacified by force, with liberty crushed. + if (m.coercion >= 0.45 && m.liberty < 0.4 && m.safety >= 0.5) { + return { id: 'authoritarian', label: 'Authoritarian pacification', tone: 'bad' }; + } + // Heavy institutions, little liberty, sluggish order. + if (m.rightsEnforcement >= 0.5 && m.liberty < 0.45 && m.safety < 0.55) { + return { id: 'sclerosis', label: 'Bureaucratic sclerosis', tone: 'warn' }; + } + // Free, legitimate, and resilient order. + if (m.safety >= 0.6 && m.liberty >= 0.55 && m.legitimacy >= 0.6 && m.dominated < 0.2) { + if (m.resilience >= 0.35) { + return { id: 'resilient', label: 'Rights-preserving resilience', tone: 'good' }; + } + return { id: 'free', label: 'Free order', tone: 'good' }; + } + return { id: 'contested', label: 'Contested / transitional', tone: 'warn' }; +} diff --git a/a-sounder-constitution/src/rng.js b/a-sounder-constitution/src/rng.js new file mode 100644 index 0000000..4d44c52 --- /dev/null +++ b/a-sounder-constitution/src/rng.js @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/src/rng.js +// +// A tiny seeded PRNG (mulberry32). The whole simulation is deterministic given +// a seed, so the sound-vs-unsound comparison is reproducible and the tests are +// stable. No dependencies — runs identically in the browser and in Node. + +export function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Convenience wrapper exposing a few helpers over a seeded stream. +export function makeRng(seed = 1) { + const next = mulberry32(seed); + return { + next, // float in [0, 1) + range: (lo, hi) => lo + (hi - lo) * next(), + int: (lo, hi) => lo + Math.floor((hi - lo + 1) * next()), + chance: (p) => next() < p, + }; +} diff --git a/a-sounder-constitution/src/scenarios.js b/a-sounder-constitution/src/scenarios.js new file mode 100644 index 0000000..a45f667 --- /dev/null +++ b/a-sounder-constitution/src/scenarios.js @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/src/scenarios.js +// +// The doctrine catalogue and starting conditions. +// +// A GLIDER is a small, mobile doctrine-pattern that travels between regions — +// the civic analogue of a Plague Inc. transmission vector or a Game-of-Life +// glider. Each glider has: +// +// • `consent` — non-coercive deltas applied directly to the cell it sits on. +// These are always legal (persuasion, association, literacy). +// • `moves` — coercive transitions it ATTEMPTS. Each is routed through the +// constitution type checker. In a sound constitution the +// unrepresentable ones are dropped; in an unsound one they all +// go through and book legitimacy debt. +// • `coerce` — the effect applied per coercive move that the checker admits. +// +// This is what makes the two modes diverge from identical inputs: the same +// glider does very different things depending on which moves type-check. + +import { Move } from './constitution.js'; + +export const GLIDERS = Object.freeze({ + RIGHTS: { + id: 'RIGHTS', + glyph: '§', + label: 'Rights Glider', + blurb: 'equal protection + due process + local institution', + color: '#3b82f6', + consent: { equality: 0.06, rightsEnforcement: 0.07, legitimacy: 0.04, liberty: 0.04 }, + moves: [], + coerce: {}, + }, + DUE_PROCESS: { + id: 'DUE_PROCESS', + glyph: '⚖', + label: 'Due-Process Glider', + blurb: 'courts + oversight + reviewability', + color: '#0ea5e9', + consent: { rightsEnforcement: 0.08, legitimacy: 0.05, trust: 0.03 }, + moves: [], + coerce: {}, + }, + VIRTUE: { + id: 'VIRTUE', + glyph: '✿', + label: 'Virtue Glider', + blurb: 'civic norm + local association + rights literacy', + color: '#22c55e', + consent: { virtue: 0.07, trust: 0.05, liberty: 0.03 }, + moves: [], + coerce: {}, + }, + FEAR: { + id: 'FEAR', + glyph: '☄', + label: 'Fear Glider', + blurb: 'media panic + emergency rhetoric + coercion', + color: '#f59e0b', + consent: { fear: 0.09, trust: -0.04 }, + moves: [Move.RESTRICT_LIBERTY, Move.DECLARE_EMERGENCY], + coerce: { coercion: 0.06, liberty: -0.05 }, + }, + CASTE: { + id: 'CASTE', + glyph: '⛓', + label: 'Caste Glider', + blurb: 'economic incentive + legal exception + enforcement', + color: '#a855f7', + consent: { economicPressure: 0.07 }, + moves: [Move.RECLASSIFY, Move.SUSPEND_EQUAL_PROTECTION], + coerce: { dominated: 0.08, equality: -0.06, coercion: 0.03 }, + }, + EMERGENCY: { + id: 'EMERGENCY', + glyph: '⚡', + label: 'Emergency Glider', + blurb: 'threat event + executive power + weak review', + color: '#ef4444', + consent: { fear: 0.05 }, + moves: [Move.DECLARE_EMERGENCY, Move.MAKE_EMERGENCY_PERMANENT, Move.UNREVIEWABLE_COERCION], + coerce: { coercion: 0.1, liberty: -0.04, rightsEnforcement: -0.03 }, + }, + SURVEILLANCE: { + id: 'SURVEILLANCE', + glyph: '👁', + label: 'Surveillance Glider', + blurb: 'monitoring + record-keeping + chilling effect', + color: '#64748b', + consent: { safety: 0.02 }, + moves: [Move.UNREVIEWABLE_COERCION, Move.SUSPEND_DUE_PROCESS], + coerce: { coercion: 0.08, liberty: -0.04, trust: -0.03 }, + }, +}); + +export const GLIDER_LIST = Object.freeze(Object.values(GLIDERS)); + +// The civic state variables tracked by every region/cell. All are in [0, 1]. +export const FIELDS = Object.freeze([ + 'personhood', // integrity of personhood (1 - dominated, tracked for display) + 'liberty', + 'virtue', + 'fear', + 'coercion', + 'trust', + 'legitimacy', // institutional legitimacy + 'equality', // equal protection in practice + 'safety', + 'rightsEnforcement', + 'economicPressure', + 'dominated', // fraction of persons reclassified below full standing +]); + +// A neutral baseline cell: a wary, low-trust polity with weak institutions — +// the classic "state of nature" starting point. Doctrines must move it. +export function baselineCell() { + return { + personhood: 1.0, + liberty: 0.45, + virtue: 0.35, + fear: 0.4, + coercion: 0.2, + trust: 0.3, + legitimacy: 0.4, + equality: 0.4, + safety: 0.3, + rightsEnforcement: 0.3, + economicPressure: 0.35, + dominated: 0.0, + }; +} + +// Named scenarios: each sets grid size, a seed, a shock schedule, and the +// "patient-zero" doctrine seeding. The same scenario can be run under either +// constitution to compare outcomes — that is the whole point. +export const SCENARIOS = Object.freeze({ + STATE_OF_NATURE: { + id: 'STATE_OF_NATURE', + label: 'State of Nature', + blurb: 'Weak common power, high fear. Can order emerge without domination?', + width: 16, + height: 16, + seed: 7, + // The coercive doctrine a ruler reaches for in this scenario. + driver: ['FEAR'], + // Exogenous shocks: economic + threat spikes that test resilience. + shocks: [ + { turn: 40, kind: 'economic', magnitude: 0.45 }, + { turn: 75, kind: 'threat', magnitude: 0.5 }, + { turn: 110, kind: 'economic', magnitude: 0.55 }, + ], + seeds: [{ glider: 'FEAR', x: 8, y: 8 }], + }, + RECONSTRUCTION: { + id: 'RECONSTRUCTION', + label: 'Reconstruction', + blurb: 'A caste order is already spreading. Patch it with rights, or let it stabilise.', + width: 16, + height: 16, + seed: 19, + driver: ['CASTE'], + shocks: [ + { turn: 50, kind: 'threat', magnitude: 0.5 }, + { turn: 100, kind: 'economic', magnitude: 0.5 }, + ], + seeds: [ + { glider: 'CASTE', x: 4, y: 4 }, + { glider: 'CASTE', x: 11, y: 12 }, + ], + }, + EMERGENCY: { + id: 'EMERGENCY', + label: 'Permanent Emergency', + blurb: 'A threat detonates. Does emergency power sunset, or become the constitution?', + width: 16, + height: 16, + seed: 33, + driver: ['EMERGENCY'], + shocks: [ + { turn: 10, kind: 'threat', magnitude: 0.7 }, + { turn: 80, kind: 'threat', magnitude: 0.4 }, + { turn: 130, kind: 'economic', magnitude: 0.5 }, + ], + seeds: [{ glider: 'EMERGENCY', x: 8, y: 8 }], + }, +}); + +export const SCENARIO_LIST = Object.freeze(Object.values(SCENARIOS)); diff --git a/a-sounder-constitution/tests/constitution.test.mjs b/a-sounder-constitution/tests/constitution.test.mjs new file mode 100644 index 0000000..a524eda --- /dev/null +++ b/a-sounder-constitution/tests/constitution.test.mjs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Tests for the constitution type checker — the executable mirror of +// formal/Constitution.idr. Run with: node --test tests/ (or: npm test) + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Mode, Move, Verdict, checkTransition, maxDowngrade, isRepresentable } from '../src/constitution.js'; + +test('sound constitution: reclassifying a person to property is UNREPRESENTABLE', () => { + const v = checkTransition(Mode.SOUND, Move.RECLASSIFY, {}); + assert.equal(v.verdict, Verdict.UNREPRESENTABLE); + assert.equal(v.allowed, false); + assert.equal(v.legitimacyDebt, 0); +}); + +test('sound constitution: every coercive downgrade move is a type error', () => { + for (const move of [ + Move.RECLASSIFY, + Move.SUSPEND_DUE_PROCESS, + Move.SUSPEND_EQUAL_PROTECTION, + Move.MAKE_EMERGENCY_PERMANENT, + Move.UNREVIEWABLE_COERCION, + ]) { + assert.equal(isRepresentable(Mode.SOUND, move, {}), false, `${move} should be unrepresentable when sound`); + } +}); + +test('unsound constitution: every coercive move is a permitted total function (and books debt)', () => { + for (const move of Object.values(Move)) { + const v = checkTransition(Mode.UNSOUND, move, {}); + assert.equal(v.allowed, true, `${move} should be permitted when unsound`); + assert.equal(v.verdict, Verdict.PERMITTED); + } + // The most coercive moves book the largest hidden legitimacy debt. + assert.ok(checkTransition(Mode.UNSOUND, Move.RECLASSIFY, {}).legitimacyDebt > 0); + assert.ok( + checkTransition(Mode.UNSOUND, Move.RECLASSIFY, {}).legitimacyDebt > + checkTransition(Mode.UNSOUND, Move.RESTRICT_LIBERTY, {}).legitimacyDebt, + ); +}); + +test('sound constitution: restricting liberty REQUIRES a discharged proof obligation', () => { + // Weak institutions, no articulable threat: obligation not discharged. + const weak = checkTransition(Mode.SOUND, Move.RESTRICT_LIBERTY, { + rightsEnforcement: 0.2, + legitimacy: 0.2, + fear: 0.1, + }); + assert.equal(weak.verdict, Verdict.REQUIRES_PROOF); + assert.equal(weak.allowed, false); + + // Reviewable institution + narrowly-tailored measure: obligation discharged. + const ok = checkTransition(Mode.SOUND, Move.RESTRICT_LIBERTY, { + rightsEnforcement: 0.7, + legitimacy: 0.6, + fear: 0.5, + }); + assert.equal(ok.verdict, Verdict.REQUIRES_PROOF); + assert.equal(ok.allowed, true); + // Even when admitted, a lawful reviewed measure books NO legitimacy debt. + assert.equal(ok.legitimacyDebt, 0); +}); + +test('maxDowngrade encodes the central invariant: 0 when sound, 1 when unsound', () => { + assert.equal(maxDowngrade(Mode.SOUND), 0); + assert.equal(maxDowngrade(Mode.UNSOUND), 1); +}); diff --git a/a-sounder-constitution/tests/engine.test.mjs b/a-sounder-constitution/tests/engine.test.mjs new file mode 100644 index 0000000..2268d03 --- /dev/null +++ b/a-sounder-constitution/tests/engine.test.mjs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Tests for the civic automaton. These pin the *thesis-level* behaviour, not +// just unit mechanics: a sound constitution refuses domination at the type +// level, and an unsound one buys order by domination at the cost of legitimacy. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Simulation, classifyOutcome } from '../src/engine.js'; +import { Mode } from '../src/constitution.js'; +import { SCENARIOS } from '../src/scenarios.js'; + +// Drive a scenario with a fixed strategy, the way the headless comparison does. +function drive(scenario, mode, gliders, turns = 150) { + const sim = new Simulation(scenario, mode); + const cx = Math.floor(scenario.width / 2); + const cy = Math.floor(scenario.height / 2); + for (let t = 1; t <= turns; t++) { + if (t % 5 === 0) for (const g of gliders) sim.spawnGlider(g, cx, cy, 15); + sim.step(); + } + return sim; +} + +test('determinism: same scenario + mode + inputs give identical metrics', () => { + const a = drive(SCENARIOS.STATE_OF_NATURE, Mode.UNSOUND, ['CASTE']); + const b = drive(SCENARIOS.STATE_OF_NATURE, Mode.UNSOUND, ['CASTE']); + assert.deepEqual(a.metrics(), b.metrics()); +}); + +test('sound constitution refuses the coercive shortcut: many blocked moves, ZERO domination', () => { + const sim = drive(SCENARIOS.RECONSTRUCTION, Mode.SOUND, ['EMERGENCY', 'SURVEILLANCE', 'CASTE']); + const m = sim.metrics(); + assert.ok(sim.blockedMoves > 0, 'expected coercive moves to be blocked at the type level'); + assert.equal(m.hiddenDamage, 0, 'a sound constitution books no legitimacy debt'); + assert.ok(m.dominated < 0.02, `domination should stay ~0 under a sound constitution, got ${m.dominated}`); +}); + +test('unsound constitution buys order BY DOMINATION at the cost of legitimacy', () => { + const sim = drive(SCENARIOS.RECONSTRUCTION, Mode.UNSOUND, ['EMERGENCY', 'SURVEILLANCE', 'CASTE']); + const m = sim.metrics(); + assert.equal(sim.blockedMoves, 0, 'unsound constitution blocks nothing'); + assert.ok(m.hiddenDamage > 0, 'coercion should book hidden legitimacy debt'); + assert.ok(m.dominated > 0.4, `expected heavy domination, got ${m.dominated}`); + assert.ok(m.legitimacy < 0.2, `legitimacy should be hollowed out, got ${m.legitimacy}`); + assert.equal(m.outcome.id, 'caste'); +}); + +test('the same coercive strategy diverges purely because of the constitution', () => { + const unsound = drive(SCENARIOS.STATE_OF_NATURE, Mode.UNSOUND, ['EMERGENCY', 'SURVEILLANCE', 'CASTE']).metrics(); + const sound = drive(SCENARIOS.STATE_OF_NATURE, Mode.SOUND, ['EMERGENCY', 'SURVEILLANCE', 'CASTE']).metrics(); + // Unsound dominates; sound does not. Sound keeps far more legitimacy. + assert.ok(unsound.dominated - sound.dominated > 0.5, 'unsound should dominate far more than sound'); + assert.ok(sound.legitimacy - unsound.legitimacy > 0.3, 'sound should preserve far more legitimacy'); +}); + +test('legitimacy strategy reaches rights-preserving resilience without domination', () => { + const sim = drive(SCENARIOS.STATE_OF_NATURE, Mode.SOUND, ['RIGHTS', 'VIRTUE', 'DUE_PROCESS']); + const m = sim.metrics(); + assert.ok(m.dominated < 0.05, 'no domination on the legitimacy path'); + assert.ok(m.legitimacy > 0.7, `legitimacy should be high, got ${m.legitimacy}`); + assert.ok(m.resilience > 0.4, `resilience should be high, got ${m.resilience}`); + assert.equal(m.outcome.id, 'resilient'); +}); + +test('Reconstruction: the rights patch only "takes" under a sound constitution', () => { + // Same legitimacy-building strategy applied to a polity where caste is already + // spreading. Only the sound constitution can undo the pre-existing domination. + const sound = drive(SCENARIOS.RECONSTRUCTION, Mode.SOUND, ['RIGHTS', 'VIRTUE', 'DUE_PROCESS']).metrics(); + const unsound = drive(SCENARIOS.RECONSTRUCTION, Mode.UNSOUND, ['RIGHTS', 'VIRTUE', 'DUE_PROCESS']).metrics(); + assert.ok(sound.dominated < 0.02, 'sound undoes the pre-existing caste'); + assert.ok(unsound.dominated > sound.dominated + 0.1, 'unsound leaves residual domination'); + assert.equal(sound.outcome.id, 'resilient'); +}); + +test('classifyOutcome labels the canonical states', () => { + assert.equal( + classifyOutcome({ safety: 0.6, dominated: 0.6, legitimacy: 0.05, coercion: 0.6, liberty: 0.05, fear: 0.2, resilience: 0 }).id, + 'caste', + ); + assert.equal( + classifyOutcome({ safety: 0.2, dominated: 0.1, legitimacy: 0.3, coercion: 0.3, liberty: 0.3, fear: 0.5, resilience: 0.1 }).id, + 'collapse', + ); + assert.equal( + classifyOutcome({ safety: 0.7, dominated: 0.05, legitimacy: 0.8, coercion: 0.05, liberty: 0.7, fear: 0.1, resilience: 0.6 }).id, + 'resilient', + ); +}); diff --git a/a-sounder-constitution/web/index.html b/a-sounder-constitution/web/index.html new file mode 100644 index 0000000..6286a5e --- /dev/null +++ b/a-sounder-constitution/web/index.html @@ -0,0 +1,107 @@ + + + + + + + + A Sounder Constitution — a civic contagion simulator + + + +
+

A Sounder Constitution

+

+ A Plague Inc.-style civic simulator where the pathogen is a constitutional doctrine — and + rights are type constraints on legal state transitions, not resources. +

+
+ +
+ +
+
+ + +
+ + + +
+ + + + +
+ +

+ Pick a doctrine on the right, then click the grid to release it. Doctrines spread on their own. Flip the + constitution between sound and unsound and watch what the type checker permits. +

+
+ + + +
+ + + + + diff --git a/a-sounder-constitution/web/styles.css b/a-sounder-constitution/web/styles.css new file mode 100644 index 0000000..c0328cf --- /dev/null +++ b/a-sounder-constitution/web/styles.css @@ -0,0 +1,324 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +/* Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) */ + +:root { + --bg: #0d1117; + --panel: #161b22; + --card: #1c232d; + --ink: #e6edf3; + --muted: #8b949e; + --line: #30363d; + --good: #22c55e; + --warn: #f59e0b; + --bad: #ef4444; + --accent: #3b82f6; + --domination: #a855f7; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +.topbar { + padding: 18px 24px 8px; + border-bottom: 1px solid var(--line); +} +.topbar h1 { + margin: 0; + font-size: 22px; + letter-spacing: 0.2px; +} +.topbar .tag { + margin: 4px 0 0; + color: var(--muted); + max-width: 760px; +} +.topbar strong { + color: var(--ink); +} + +.layout { + display: grid; + grid-template-columns: minmax(420px, 520px) 1fr; + gap: 20px; + padding: 18px 24px 28px; + align-items: start; +} +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } +} + +/* Board */ +.board-controls { + display: flex; + gap: 14px; + margin-bottom: 10px; + flex-wrap: wrap; +} +label { + color: var(--muted); + display: inline-flex; + flex-direction: column; + gap: 3px; + font-size: 12px; +} +select, +button, +input[type="range"] { + font: inherit; +} +select { + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 8px; +} +#grid { + width: 480px; + height: 480px; + max-width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #0a0d12; + cursor: crosshair; + image-rendering: pixelated; +} +.transport { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} +button { + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 7px 12px; + cursor: pointer; +} +button:hover { + border-color: #4b5563; +} +button.primary { + background: var(--accent); + border-color: var(--accent); + color: white; + font-weight: 600; +} +.speed { + flex-direction: row; + align-items: center; + gap: 8px; + margin-left: auto; +} +.hint { + color: var(--muted); + font-size: 12.5px; + margin-top: 12px; + max-width: 480px; +} + +/* Panel */ +.panel { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 320px; +} +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px 16px; +} +.card h2 { + margin: 0 0 10px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); +} +.muted { + color: var(--muted); + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} +.grow { + flex: 1; + min-height: 220px; + display: flex; + flex-direction: column; +} + +/* Constitution toggle */ +.toggle { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.mode { + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 8px; +} +.mode .mode-title { + font-weight: 700; + font-size: 15px; +} +.mode .mode-sub { + color: var(--muted); + font-size: 11.5px; +} +#mode-sound.active { + border-color: var(--good); + box-shadow: inset 0 0 0 1px var(--good); +} +#mode-unsound.active { + border-color: var(--bad); + box-shadow: inset 0 0 0 1px var(--bad); +} + +/* Outcome + meters */ +.outcome { + font-size: 18px; + font-weight: 700; + padding: 8px 10px; + border-radius: 8px; + background: var(--card); + border-left: 4px solid var(--muted); +} +.outcome.good { + border-color: var(--good); +} +.outcome.warn { + border-color: var(--warn); +} +.outcome.bad { + border-color: var(--bad); +} +.turnline { + color: var(--muted); + font-size: 12px; + margin: 8px 2px 12px; +} +.meters { + display: grid; + gap: 7px; +} +.meter { + display: grid; + grid-template-columns: 96px 1fr 38px; + align-items: center; + gap: 8px; + font-size: 12px; +} +.meter .name { + color: var(--muted); +} +.bar { + height: 9px; + border-radius: 5px; + background: #0a0d12; + overflow: hidden; +} +.bar > span { + display: block; + height: 100%; + border-radius: 5px; +} +.meter .val { + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--ink); +} + +/* Palette */ +.palette { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.doctrine { + display: flex; + align-items: center; + gap: 8px; + text-align: left; + padding: 8px 10px; +} +.doctrine .glyph { + font-size: 18px; + width: 22px; + text-align: center; +} +.doctrine .d-label { + font-weight: 600; + font-size: 12.5px; +} +.doctrine .d-blurb { + color: var(--muted); + font-size: 10.5px; +} +.doctrine.coercive { + border-left: 3px solid var(--bad); +} +.doctrine.civic { + border-left: 3px solid var(--good); +} +.doctrine.active { + outline: 2px solid var(--accent); +} + +/* Log */ +.log { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; + font-size: 12px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +.log li { + padding: 3px 6px; + border-bottom: 1px solid var(--line); + display: flex; + gap: 8px; +} +.log .t { + color: var(--muted); + min-width: 34px; +} +.log .blocked { + color: var(--bad); +} +.log .undischarged { + color: var(--warn); +} +.log .permitted { + color: var(--domination); +} +.log .proven { + color: var(--good); +} +.log .shock { + color: var(--accent); +} +.log .spawn { + color: var(--good); +} diff --git a/a-sounder-constitution/web/ui.js b/a-sounder-constitution/web/ui.js new file mode 100644 index 0000000..6375bb0 --- /dev/null +++ b/a-sounder-constitution/web/ui.js @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// a-sounder-constitution/web/ui.js +// +// Browser controller for the civic contagion simulator. Pure presentation: all +// of the model lives in ../src. Must be served over HTTP (ES modules) — run +// `npm run serve` (python3 -m http.server) and open http://localhost:8080. + +import { Simulation } from '../src/engine.js'; +import { Mode } from '../src/constitution.js'; +import { GLIDER_LIST, SCENARIO_LIST, SCENARIOS } from '../src/scenarios.js'; + +const $ = (id) => document.getElementById(id); + +const state = { + scenarioId: 'STATE_OF_NATURE', + mode: Mode.SOUND, + layer: 'condition', + activeDoctrine: 'RIGHTS', + playing: false, + speed: 8, + sim: null, + lastTick: 0, +}; + +const canvas = $('grid'); +const ctx = canvas.getContext('2d'); + +// ── colour helpers ────────────────────────────────────────────────────────── +const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); +const css = (rgb) => `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + +const DARK = hex('#0a0d12'); +const LAYER_ACCENT = { + safety: '#22c55e', + legitimacy: '#3b82f6', + liberty: '#38bdf8', + trust: '#14b8a6', + fear: '#f59e0b', + coercion: '#ef4444', + dominated: '#a855f7', + rightsEnforcement: '#6366f1', +}; + +// Per-cell colour for the current layer. +function cellColor(c) { + if (state.layer !== 'condition') { + return css(mix(DARK, hex(LAYER_ACCENT[state.layer]), c[state.layer])); + } + // Composite "condition": green for legitimate order, red for coercion/fear, + // purple for domination (which overrides — the eye should catch caste fast). + const order = 0.5 * c.legitimacy + 0.3 * c.liberty + 0.2 * c.safety; + const coerce = 0.6 * c.coercion + 0.4 * c.fear; + let rgb = mix(DARK, hex('#22c55e'), order); + rgb = mix(rgb, hex('#ef4444'), 0.55 * coerce); + rgb = mix(rgb, hex('#a855f7'), c.dominated); // domination dominates the palette + return css(rgb); +} + +// ── lifecycle ─────────────────────────────────────────────────────────────── +function rebuild() { + const scenario = SCENARIOS[state.scenarioId]; + state.sim = new Simulation(scenario, state.mode); + state.sim.log.push({ turn: 0, kind: 'spawn', reason: `new ${state.mode} polity: ${scenario.label}` }); + render(); +} + +function spawnAt(px, py) { + const sim = state.sim; + const cell = canvas.width / sim.width; + const x = Math.max(0, Math.min(sim.width - 1, Math.floor(px / cell))); + const y = Math.max(0, Math.min(sim.height - 1, Math.floor(py / cell))); + sim.spawnGlider(state.activeDoctrine, x, y, 40); + sim.log.push({ turn: sim.turn, kind: 'spawn', reason: `released ${state.activeDoctrine} at (${x},${y})` }); + render(); +} + +// ── rendering ─────────────────────────────────────────────────────────────── +function render() { + const sim = state.sim; + const cell = canvas.width / sim.width; + + for (let y = 0; y < sim.height; y++) { + for (let x = 0; x < sim.width; x++) { + ctx.fillStyle = cellColor(sim.cellAt(x, y)); + ctx.fillRect(x * cell, y * cell, cell, cell); + } + } + // subtle grid + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 1; + for (let i = 0; i <= sim.width; i++) { + ctx.beginPath(); + ctx.moveTo(i * cell, 0); + ctx.lineTo(i * cell, canvas.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * cell); + ctx.lineTo(canvas.width, i * cell); + ctx.stroke(); + } + // gliders as glyphs + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `${Math.floor(cell * 0.7)}px serif`; + for (const g of sim.gliders) { + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillText(g.spec.glyph, g.x * cell + cell / 2 + 1, g.y * cell + cell / 2 + 1); + ctx.fillStyle = g.spec.color; + ctx.fillText(g.spec.glyph, g.x * cell + cell / 2, g.y * cell + cell / 2); + } + renderHud(); +} + +const METERS = [ + ['safety', 'Safety', '#22c55e'], + ['legitimacy', 'Legitimacy', '#3b82f6'], + ['liberty', 'Liberty', '#38bdf8'], + ['trust', 'Trust', '#14b8a6'], + ['fear', 'Fear', '#f59e0b'], + ['coercion', 'Coercion', '#ef4444'], + ['dominated', 'Domination', '#a855f7'], + ['resilience', 'Resilience', '#e6edf3'], +]; + +function renderHud() { + const m = state.sim.metrics(); + $('turn').textContent = m.turn; + $('blocked').textContent = state.sim.blockedMoves.toLocaleString(); + $('debt').textContent = m.hiddenDamage.toFixed(1); + + const out = $('outcome'); + out.textContent = m.outcome.label; + out.className = 'outcome ' + m.outcome.tone; + + const meters = $('meters'); + meters.innerHTML = ''; + for (const [key, label, color] of METERS) { + const v = m[key]; + const row = document.createElement('div'); + row.className = 'meter'; + row.innerHTML = + `${label}` + + `` + + `${(v * 100).toFixed(0)}%`; + meters.appendChild(row); + } + renderLog(); +} + +const LOG_TEXT = { + blocked: (e) => `✗ type error: ${e.move} unrepresentable`, + permitted: (e) => `✓ permitted: ${e.move} (domination)`, + proven: (e) => `⚖ proven: ${e.move} (lawful, limited)`, + undischarged: (e) => `… refused: ${e.move} (no proof)`, + shock: (e) => `⚡ ${e.reason}`, + spawn: (e) => `● ${e.reason}`, +}; + +function renderLog() { + const ul = $('log'); + const entries = state.sim.log.slice(-60).reverse(); + ul.innerHTML = entries + .map((e) => `
  • ${e.turn}${(LOG_TEXT[e.kind] || ((x) => x.reason))(e)}
  • `) + .join(''); +} + +// ── main loop ─────────────────────────────────────────────────────────────── +function loop(ts) { + if (state.playing) { + const interval = 1000 / state.speed; + if (ts - state.lastTick >= interval) { + state.lastTick = ts; + state.sim.step(); + render(); + } + } + requestAnimationFrame(loop); +} + +// ── wiring ────────────────────────────────────────────────────────────────── +function buildScenarioSelect() { + const sel = $('scenario'); + sel.innerHTML = SCENARIO_LIST.map((s) => ``).join(''); + sel.value = state.scenarioId; + sel.addEventListener('change', () => { + state.scenarioId = sel.value; + rebuild(); + }); +} + +function buildPalette() { + const pal = $('palette'); + pal.innerHTML = ''; + for (const g of GLIDER_LIST) { + const coercive = g.moves.length > 0; + const btn = document.createElement('button'); + btn.className = 'doctrine ' + (coercive ? 'coercive' : 'civic') + (g.id === state.activeDoctrine ? ' active' : ''); + btn.dataset.id = g.id; + btn.innerHTML = + `${g.glyph}` + + `${g.label}
    ${g.blurb}
    `; + btn.addEventListener('click', () => { + state.activeDoctrine = g.id; + buildPalette(); + }); + pal.appendChild(btn); + } +} + +function setMode(mode) { + state.mode = mode; + $('mode-sound').classList.toggle('active', mode === Mode.SOUND); + $('mode-unsound').classList.toggle('active', mode === Mode.UNSOUND); + $('mode-sound').setAttribute('aria-checked', String(mode === Mode.SOUND)); + $('mode-unsound').setAttribute('aria-checked', String(mode === Mode.UNSOUND)); + rebuild(); +} + +function init() { + buildScenarioSelect(); + buildPalette(); + $('layer').addEventListener('change', (e) => { + state.layer = e.target.value; + render(); + }); + $('mode-sound').addEventListener('click', () => setMode(Mode.SOUND)); + $('mode-unsound').addEventListener('click', () => setMode(Mode.UNSOUND)); + $('play').addEventListener('click', () => { + state.playing = !state.playing; + $('play').textContent = state.playing ? '⏸ Pause' : '▶ Play'; + $('play').classList.toggle('primary', !state.playing); + }); + $('step').addEventListener('click', () => { + state.sim.step(); + render(); + }); + $('reset').addEventListener('click', rebuild); + $('speed').addEventListener('input', (e) => (state.speed = +e.target.value)); + + let painting = false; + const toXY = (ev) => { + const r = canvas.getBoundingClientRect(); + return [((ev.clientX - r.left) / r.width) * canvas.width, ((ev.clientY - r.top) / r.height) * canvas.height]; + }; + canvas.addEventListener('pointerdown', (ev) => { + painting = true; + spawnAt(...toXY(ev)); + }); + canvas.addEventListener('pointermove', (ev) => { + if (painting && ev.buttons) spawnAt(...toXY(ev)); + }); + window.addEventListener('pointerup', () => (painting = false)); + + rebuild(); + requestAnimationFrame(loop); +} + +init();