SPB Git

spb/wp35_uqo

Public

WP35 Phase 3: formal framework (5 theorems, 3 conjectures), D/K/P indices with IRT alternative, measurement diagnostics, a-priori power

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 1, 2026) parent 8621449 Browse files

16 changed files +1,139 −0

added data/processed/modern_indices.parquet +0 −0

Binary file not shown.

added data/processed/seshat_crisis_indices.parquet +0 −0

Binary file not shown.

added data/processed/seshat_indices.parquet +0 −0

Binary file not shown.

added paper/figures/fig_irt_invariance.pdf +0 −0

Binary file not shown.

added paper/figures/fig_measurement_DK.pdf +0 −0

Binary file not shown.

modified research/bib_overrides.csv +2 −0
@@ -47,3 +47,5 @@ marzen2017|10.1098/rsif.2017.0166|Marzen & DeDeo 2017 J R Soc Interface — adde
47 flack2012|10.1098/rstb.2011.0214|Flack 2012 Phil Trans B — added after novelty search 47 flack2012|10.1098/rstb.2011.0214|Flack 2012 Phil Trans B — added after novelty search
48 hoyer2024|10.31235/osf.io/rk4gd|Hoyer et al. 2024 SocArXiv preprint 'All crises are unhappy in their own way' — no journal version found 2026-11-01 48 hoyer2024|10.31235/osf.io/rk4gd|Hoyer et al. 2024 SocArXiv preprint 'All crises are unhappy in their own way' — no journal version found 2026-11-01
49 wolpert2017|10.1017/9781316584200.010|Wolpert 2017 'The many faces of state space compression' (same volume) 49 wolpert2017|10.1017/9781316584200.010|Wolpert 2017 'The many faces of state space compression' (same volume)
50+lindley1952|10.1017/s0305004100027638|Lindley 1952 Math. Proc. Cambridge Phil. Soc. 48 — verified by Crossref (used in framework.md Theorem 5)
51+loynes1962|10.1017/s0305004100036781|Loynes 1962 Math. Proc. Cambridge Phil. Soc. 58 — verified by Crossref
added research/framework.md +264 −0
@@ -0,0 +1,264 @@
1+# WP35 — Formal framework: coordination demand, coordination capacity, and institutions as codes
2+
3+Status labels used throughout: **DEFINITION** (a convention), **THEOREM / PROPOSITION** (a statement proved below
4+or a standard result with the proof reproduced), **CONJECTURE** (a statement we believe is worth testing, with no
5+proof; what would falsify it is stated). Nothing in this file is an empirical finding. The results concern an
6+idealized communication system; §8 states what is *not* claimed and which pre-registered test (if any) can speak to
7+each statement. Citations use the keys of `research/reference_candidates.csv`.
8+
9+---
10+
11+## 1. Primitives and definitions
12+
13+**DEFINITION 1.1 (agents, tasks, plans).** A society has $N$ agents. In each period, coordination tasks arrive at rate
14+$\lambda$ per agent. A task requires that $k \ge 2$ participants settle on a common element of a finite *plan set*
15+$S$, $|S| = s$, the required plan being drawn independently across tasks from a distribution $p$ on $S$ with Shannon
16+entropy $H(p) = -\sum_{x \in S} p(x)\log_2 p(x) \le \log_2 s$ (shannon1948; cover2006). Communication is counted in
17+*messages* (one transmission between two agents) and in *bits* (the length of what is transmitted). Verification and
18+record-keeping use a second channel whose cost is counted separately as *administrative overhead* (DEFINITION 2.1).
19+
20+**DEFINITION 1.2 (coordination demand).** The coordination demand of a period is the task volume that must be
21+settled: $D = N \lambda k$ participant-slots per period. In the worst case in which every pair of agents must be
22+reconciled, $D$ is bounded by the number of pairs, $N(N-1)/2$, i.e. $D = \Theta(N^2)$; under bounded $\lambda$ and
23+$k$, $D = \Theta(N)$. Either way $D$ is increasing in $N$.
24+
25+**DEFINITION 1.3 (coordination capacity).** Fix a tolerated error rate $\varepsilon \in (0,1)$. The coordination
26+capacity $K$ of an institutional system is the maximal number of tasks it can settle per period such that the
27+fraction of tasks settled on a wrong or inconsistent plan is at most $\varepsilon$. $K$ depends on the available
28+communication budget (bits per period), the code in use (§2), the routing structure (§5) and the overhead (§3).
29+
30+**DEFINITION 1.4 (utilization).** $\rho = D/K$. When $D$ and $K$ are measured in the same units (tasks per period),
31+$\log D - \log K = \log \rho$ exactly.
32+
33+**Limitation (not a theorem).** The pre-registered empirical index $P = z(\log D) - z(K)$ uses standardized proxies
34+(population, territory, settlement hierarchy for $D$; shares of administrative, legal/monetary, communication and
35+record-keeping indicators for $K$). It is a monotone proxy for $\log\rho$ only under CONJECTURE C1 (§7); it is not a
36+measurement of $\rho$, and the Seshat/V-Dem indicators are not Shannon quantities.
37+
38+---
39+
40+## 2. Theorem 1 — coding gain of a shared code
41+
42+**DEFINITION 2.1 (code).** A *shared code* is an injective map $c: S \to \{0,1\}^*$ known to all participants,
43+with lengths $\ell(x) = |c(x)|$. It is *prefix-free* if no codeword is a prefix of another. Institutions in the sense of
44+this paper (standardized names, units, legal forms, accounting categories, calendars) are shared codes in exactly this
45+sense (arrow1974; cremer2007).
46+
47+**Assumption 2.2.** Settling a task means that the initiating participant identifies the required plan $x \in S$ to the
48+other $k-1$ participants without ambiguity; messages to different participants are counted separately in messages
49+but identically in bits per recipient, so we state the result per recipient.
50+
51+**THEOREM 1 (coding gain).**
52+(i) Without a shared code, identifying an arbitrary element of $S$ without error requires at least
53+$\lceil \log_2 s \rceil$ bits per recipient in the worst case.
54+(ii) With a shared prefix-free code that is optimal for $p$, the expected length per recipient
55+$L_p^\* = \min_{c\ \mathrm{prefix\text{-}free}} \sum_x p(x)\ell(x)$ satisfies
56+$$H(p) \le L_p^\* < H(p) + 1 .$$
57+(iii) Hence the expected saving per recipient, $G(p) = \lceil\log_2 s\rceil - L_p^\*$, obeys
58+$G(p) > \lceil\log_2 s\rceil - H(p) - 1 \ge 0$ whenever $\lceil\log_2 s\rceil \ge H(p)+1$, and $G(p)$ is strictly
59+positive as soon as $p$ is sufficiently concentrated ($H(p) < \lceil\log_2 s\rceil - 1$).
60+
61+*Proof.* (i) Without a shared code there is no prior agreement on which binary string denotes which plan, so the
62+only error-free strategy is a fixed-length enumeration of $S$ agreed by the trivial convention of listing; any scheme
63+that distinguishes $s$ alternatives with binary strings of a fixed length $m$ needs $2^m \ge s$, i.e.
64+$m \ge \lceil\log_2 s\rceil$. (The point is not that $\log_2 s$ is a lower bound for *all* conceivable codes — it is the
65+cost of the *codeless* baseline, in which the message must be self-describing.)
66+
67+(ii) *Lower bound.* For any prefix-free code the Kraft inequality holds: $\sum_x 2^{-\ell(x)} \le 1$ (cover2006,
68+Thm 5.2.1). Put $q(x) = 2^{-\ell(x)}/Z$ with $Z = \sum_x 2^{-\ell(x)} \le 1$. Then
69+$$\sum_x p(x)\ell(x) - H(p) = \sum_x p(x)\log_2\frac{p(x)}{2^{-\ell(x)}} = \sum_x p(x)\log_2\frac{p(x)}{q(x)} - \log_2 Z
70+= D_{KL}(p\|q) - \log_2 Z \ge 0,$$
71+because $D_{KL} \ge 0$ (Gibbs' inequality) and $-\log_2 Z \ge 0$. *Upper bound.* Choose
72+$\ell(x) = \lceil -\log_2 p(x)\rceil$. Then $\sum_x 2^{-\ell(x)} \le \sum_x p(x) = 1$, so by the converse Kraft
73+theorem (cover2006, Thm 5.2.1) a prefix-free code with these lengths exists, and
74+$\sum_x p(x)\ell(x) < \sum_x p(x)(-\log_2 p(x) + 1) = H(p) + 1$. The optimal code does at least as well.
75+
76+(iii) Subtract (ii) from (i). $\square$
77+
78+**Remark 2.3.** Theorem 1 is the source-coding theorem of shannon1948 applied to the plan set. Its content for the
79+paper is only this: *a shared code converts a worst-case identification cost into an average-case cost that is lower
80+exactly to the extent that tasks are predictable.* The "compression" in the title of the hypothesis refers to this
81+step and to nothing else.
82+
83+---
84+
85+## 3. Theorem 2 — break-even scale of an institution
86+
87+**DEFINITION 3.1 (institutional cost structure).** Adopting a code costs a fixed set-up $F \ge 0$ (designing and
88+promulgating the standard), a learning cost $\ell \ge 0$ per agent (each of the $N$ agents must learn the code), and an
89+administrative overhead $a \ge 0$ bits per task (verification, record-keeping, enforcement). It saves $G > 0$ bits per
90+task (Theorem 1, per recipient; multiply by $k-1$ if desired — the argument is unchanged).
91+
92+**THEOREM 2 (break-even).** Over a horizon with $T$ tasks, the institution lowers total communication cost if and only
93+if
94+$$T\,(G - a) > F + N\ell .$$
95+With $T = N\lambda\tau$ over $\tau$ periods, this is equivalent to
96+$$\lambda\tau > \frac{F/N + \ell}{G - a}\quad\text{when } G > a,$$
97+and the inequality can never hold when $a \ge G$.
98+
99+*Proof.* Total cost without the institution is $C_0 = T\lceil\log_2 s\rceil$. With it, $C_1 = F + N\ell + T(L_p^\* + a)$.
100+$C_1 < C_0 \iff F + N\ell < T(\lceil\log_2 s\rceil - L_p^\* - a) = T(G - a)$. If $a \ge G$ the right-hand side is
101+$\le 0 < F + N\ell$ unless $F = \ell = 0$ and $a = G$, in which case the costs are equal, so strict improvement is
102+impossible. If $G > a$, substitute $T = N\lambda\tau$ and divide both sides by $N(G-a) > 0$. $\square$
103+
104+**Corollary 3.2 (scale advantage).** The threshold $(F/N + \ell)/(G-a)$ is decreasing in $N$: holding $\lambda,\tau$
105+fixed, a larger society crosses the break-even point sooner, because the set-up cost is spread over more agents. It is
106+*not* decreasing in $N$ through $\ell$: per-agent learning costs do not benefit from scale. This is the formal version
107+of the observation that institutions "pay off at scale" (johnson1982; garicano2000; bolton1994), and it also makes
108+precise when they do not: when overhead $a$ is close to the coding gain $G$, or when horizons $\tau$ are short.
109+
110+---
111+
112+## 4. Theorem 3 — mismatched code penalty (institutional rigidity)
113+
114+**THEOREM 3 (cost of a stale code).** Let the code be optimal for $p$ in the sense of using lengths
115+$\ell_p(x) = \lceil -\log_2 p(x) \rceil$, and let the true task distribution be $q$ with $q \ll p$ (every plan with
116+positive $q$-probability has positive $p$-probability). Then the expected length under $q$ satisfies
117+$$H(q) + D_{KL}(q\|p) \le \mathbb{E}_q[\ell_p(X)] < H(q) + D_{KL}(q\|p) + 1 .$$
118+The excess cost of using the $p$-code when the world is $q$, relative to the $q$-optimal code, lies in
119+$(D_{KL}(q\|p) - 1,\ D_{KL}(q\|p) + 1)$ bits per task.
120+
121+*Proof.* Since $-\log_2 p(x) \le \ell_p(x) < -\log_2 p(x) + 1$,
122+$$\mathbb{E}_q[\ell_p(X)] \ge \sum_x q(x)\left(-\log_2 p(x)\right) = \sum_x q(x)\log_2\frac{q(x)}{p(x)} + \sum_x q(x)\left(-\log_2 q(x)\right) = D_{KL}(q\|p) + H(q),$$
123+and the same computation with the $+1$ gives the strict upper bound. The $q$-optimal code has expected length in
124+$[H(q), H(q)+1)$ by Theorem 1(ii), which yields the stated interval for the excess. (This is the "wrong code" result of
125+cover2006, Thm 5.4.3.) $\square$
126+
127+**Corollary 4.1 (fragility).** A shock that moves the task distribution from $p$ to $q$ raises the per-task
128+communication cost of an existing institution by about $D_{KL}(q\|p)$ bits. Combined with Theorem 2, the institution
129+remains worthwhile after the shock only if $G(p) - D_{KL}(q\|p) > a$ (up to the $\pm 1$ rounding). An institution can
130+therefore *lower* efficiency after a shock, and the more specialized the code (the smaller $H(p)$ relative to
131+$\log_2 s$), the larger the divergence a given shock can produce. This is the precise sense in which standardization
132+trades efficiency for rigidity; it echoes tainter1988's declining marginal returns but gives the mechanism a unit (bits
133+per task) and a magnitude (a divergence).
134+
135+---
136+
137+## 5. Theorem 4 — hierarchical routing bound and root load
138+
139+**DEFINITION 5.1 (hierarchy).** A hierarchy is a rooted tree whose $N$ leaves are the agents and whose internal
140+nodes are administrative units; every internal node has at most $b \ge 2$ children; the depth (leaf-to-root distance)
141+is $d = \lceil \log_b N \rceil$ for a complete $b$-ary tree. A task between two leaves is *settled by relay* when each
142+participant sends a message up to their lowest common ancestor (LCA) and the LCA sends the settlement down.
143+
144+**THEOREM 4.**
145+(i) *Message bound.* Any pairwise task can be settled with at most $2d$ messages (up-path plus down-path through the
146+LCA), versus up to $N-1$ messages for a flat broadcast in which the initiator must reach every other agent. For $M$
147+pairwise tasks the total is at most $2M\lceil\log_b N\rceil$.
148+(ii) *Root load.* In a complete $b$-ary tree with $N = b^d$ leaves, if the two participants of a task are drawn
149+uniformly at random among leaves (independently, with replacement), the probability that their LCA is the root is
150+$1 - 1/b$. Hence the expected number of tasks that pass through the root is $M(1 - 1/b)$.
151+(iii) *Federated / modular coordination.* If a fraction $\theta \in [0,1]$ of tasks is *local* — both participants
152+belong to the same first-level module (child subtree of the root) by construction of the task process — and the
153+remaining $1-\theta$ are drawn uniformly as in (ii), the expected root load is $M(1-\theta)(1 - 1/b)$.
154+
155+*Proof.* (i) In a tree there is a unique simple path between two leaves; it passes through their LCA and has length at
156+most $2d$ since each leaf is at depth $\le d$ and the LCA is at depth $\ge 0$. Sending one message per edge along
157+the two leaf-to-LCA paths settles the task in $\le 2d$ messages. A flat broadcast from one initiator to the other
158+$N-1$ agents uses $N-1$ messages. Summing over $M$ tasks gives (i).
159+(ii) The root has $b$ children, each the root of a subtree with $b^{d-1}$ leaves. The first participant lies in some
160+child subtree $j$; the second participant, drawn uniformly and independently, lies in the same subtree $j$ with
161+probability $b^{d-1}/b^d = 1/b$. The LCA is the root exactly when the two participants lie in different child subtrees,
162+which has probability $1 - 1/b$. Expectation of a sum of $M$ indicators gives $M(1-1/b)$.
163+(iii) Local tasks never reach the root (their LCA lies inside a module); the non-local fraction behaves as in (ii).
164+By linearity of expectation the load is $M(1-\theta)(1-1/b)$. $\square$
165+
166+**Remark 5.2.** Theorem 4 formalizes the trade-off between centralized administration and federated coordination
167+(radner1993; bolton1994): a hierarchy cuts the *message* cost of coordination from linear to logarithmic in $N$, but
168+concentrates a constant fraction $1 - 1/b$ of all non-local tasks on the apex, whose processing rate then bounds $K$
169+(Definition 1.3). Modularity reduces apex load by the locality fraction $\theta$ at the price of *not* reconciling
170+across modules. These are the five regimes compared in the pre-registered simulations (no institution, shared norms,
171+standardized records, centralized administration, federated), and the simulations measure messages and bytes
172+directly rather than assuming the bounds.
173+
174+---
175+
176+## 6. Theorem 5 — backlog dynamics under mismatch
177+
178+**DEFINITION 6.1 (backlog).** Let $D_t$ be the number of tasks arriving in period $t$ and $K_t$ the number the system
179+can settle in period $t$. The backlog of unsettled tasks follows the Lindley recursion
180+$$B_{t+1} = \max\{0,\ B_t + D_t - K_t\},\qquad B_0 \ge 0 .$$
181+
182+**THEOREM 5.**
183+(i) *Deterministic case.* If $D_t = D$ and $K_t = K$ for all $t$, then: if $D \le K$, $B_t \le \max\{B_0 - t(K-D), 0\}$,
184+so the backlog reaches $0$ after at most $\lceil B_0/(K-D) \rceil$ periods when $D < K$ and stays constant at $B_0$
185+when $D = K$; if $D > K$, $B_t = B_0 + t(D-K) \to \infty$ linearly.
186+(ii) *Stochastic case.* If $(D_t - K_t)_{t\ge0}$ is i.i.d. with finite mean, then when $\mathbb{E}[D_t] < \mathbb{E}[K_t]$
187+the backlog has a proper stationary distribution to which $B_t$ converges from any $B_0$, and when
188+$\mathbb{E}[D_t] > \mathbb{E}[K_t]$, $B_t \to \infty$ almost surely. (Standard: the recursion is the single-server
189+waiting-time recursion of Lindley (1952), and the stability dichotomy is Loynes' theorem (1962); the proof for (ii) is
190+not reproduced here and the result is cited.)
191+(iii) *Delay under a Markovian approximation.* If arrivals are Poisson with rate $\lambda_D$ and settlements are
192+exponential with rate $\mu$ (an M/M/1 queue), with $\rho = \lambda_D/\mu < 1$, the expected sojourn time is
193+$W = 1/(\mu - \lambda_D) = (1/\mu)/(1-\rho)$, which is increasing and convex in $\rho$ on $[0,1)$ and diverges as
194+$\rho \uparrow 1$.
195+
196+*Proof of (i).* If $D \le K$, then $B_{t+1} = \max\{0, B_t - (K-D)\}$; by induction
197+$B_t \le \max\{0, B_0 - t(K-D)\}$, with equality as long as the argument is nonnegative, which gives the hitting time
198+when $D < K$ and constancy when $D = K$. If $D > K$, then $B_t + D - K > 0$ for all $t$ (since $B_t \ge 0$), so the
199+maximum is never binding and $B_{t+1} = B_t + (D-K)$, hence $B_t = B_0 + t(D-K)$.
200+*Proof of (iii).* The M/M/1 stationary number in system is geometric with parameter $\rho$, so the mean is
201+$\rho/(1-\rho)$ and Little's law gives $W = \rho/((1-\rho)\lambda_D) = 1/(\mu(1-\rho)) = 1/(\mu - \lambda_D)$ (any
202+standard queueing reference). The function $f(\rho) = (1-\rho)^{-1}$ has $f' = (1-\rho)^{-2} > 0$ and
203+$f'' = 2(1-\rho)^{-3} > 0$ on $[0,1)$, and $f(\rho) \to \infty$ as $\rho \uparrow 1$. $\square$
204+
205+**Scope of Theorem 5.** This is a *mechanism*, not evidence. Polities are not single-server queues: capacity is
206+endogenous (institutions are built in response to backlog), arrivals are correlated across periods and agents, and
207+"unsettled tasks" are not observed in the historical record. The theorem is used in the paper only to motivate two
208+qualitative predictions — that costs rise smoothly and convexly in utilization, and that sustained $\rho > 1$ is
209+qualitatively different from $\rho < 1$ — and both are stated as conjectures below.
210+
211+---
212+
213+## 7. Conjectures (unproved; each with its falsifier)
214+
215+**CONJECTURE C1 (proxy monotonicity).** The pre-registered Seshat and V-Dem proxies are monotone in the model's
216+primitives: the scale variables (population, territory, largest settlement, settlement hierarchy) increase with
217+$D$ of Definition 1.2, and the four indicator blocks (administration, standardized rules and money, communication
218+infrastructure, record-keeping) increase with $K$ of Definition 1.3, so that $P = z(\log D) - z(K)$ is monotone in
219+$\log\rho$ up to measurement error. *Reason:* population and territory raise the number and the dispersion of
220+participants; written records, legal codes, money and measurement systems are shared codes (Theorem 1); roads,
221+couriers and postal systems raise bits per period; administrative levels implement the routing of Theorem 4.
222+*Falsifier:* the proxies for $D$ and $K$ are so collinear (as Seshat's first principal component suggests; shin2020)
223+that $P$ retains almost no variance, or alternative defensible $K$ weightings reverse the sign of $P$ for a
224+substantial share of polities — both are reported in `research/measurement.md`.
225+
226+**CONJECTURE C2 (sustained overload raises breakdown probability).** Periods with $\rho > 1$ sustained longer than
227+the time needed to build capacity raise the probability of institutional breakdown. *Mechanism family (not proved):*
228+unsettled tasks accumulate (Theorem 5), so participants cannot form the common knowledge required for coordinated
229+action (chwe2000) and local defection spreads by contagion through the interaction network (morris2000); institutions
230+whose codes are stale after a shock lose their cost advantage (Theorem 3) and are abandoned. *Falsifier:* the
231+pre-registered H2 tests find $\beta_P$ not positive, or $P$ does not beat the population-only and capacity-only
232+baselines out of sample.
233+
234+**CONJECTURE C3 (smooth, not step).** The relation between $P$ and instability is convex and smooth away from
235+$\rho \approx 1$ and steepens near it, rather than a discrete threshold. *Reason:* Theorem 5(iii) gives a smooth,
236+convex delay in $\rho$; discontinuities would require an additional tipping mechanism (e.g. a critical mass in a
237+contagion model) that the framework does not supply. *Falsifier:* the pre-registered H4 comparison finds that a hinge
238+or step model beats the smooth model out of sample in $\ge 80\%$ of folds, which would favour a tipping mechanism;
239+conversely, if neither nonlinear form beats the linear model, C3's "steepening" clause is unsupported.
240+
241+---
242+
243+## 8. What is not claimed, and which test speaks to each statement
244+
245+- **No Shannon measurement of history.** Theorems 1 and 3 are about codes for a plan set with a known distribution.
246+ Seshat indicators (presence of writing, coins, courts, roads) are not message lengths, and no entropy or divergence
247+ is computed from historical data anywhere in this project. Shannon quantities appear only in the simulations, where
248+ the plan distribution $p$ and the messages are defined by the experimenter and can be measured.
249+- **No causal claim.** The theorems describe an idealized communication system; the empirical tests are associational
250+ and pre-registered as such (`research/preregistration.md`, §1 and §5); the identification discussion is in
251+ `research/identification.md`.
252+- **Endogeneity of capacity.** The framework takes $K$ as given within a period; historically, $K$ is built in response
253+ to $D$ (johnson1982; shin2020). This is why H1 is pre-registered as an association without a direction.
254+
255+| Statement | What can speak to it | Pre-registered test |
256+|---|---|---|
257+| Theorem 1 (coding gain) | Simulated messages and bits with vs. without a shared code | H3 (regimes b, c vs a) |
258+| Theorem 2 (break-even) | Simulated total cost across $N$, $\lambda$, horizon, overhead $a$ | H3 factorial cells; search for cells where institutions lose |
259+| Theorem 3 (stale code) | Simulated shocks to the task distribution | H3 adverse regimes (shock treatment) |
260+| Theorem 4 (routing, root load) | Simulated messages and apex load under centralized vs federated regimes | H3 (regimes d, e) |
261+| Theorem 5 (backlog) | Not testable with the historical data (backlog unobserved); illustrated in simulation only | none (stated as mechanism) |
262+| Conjecture C1 | Measurement diagnostics: collinearity of $D$ and $K$ proxies, sensitivity of $P$ to weights | `research/measurement.md` |
263+| Conjecture C2 | Lagged $P$ and subsequent instability vs baselines, out of sample | H2 (Seshat Y1–Y3; V-Dem Y4–Y6), H5 |
264+| Conjecture C3 | Linear vs hinge vs spline in $P$, out of sample | H4 |
added research/measurement.md +80 −0
@@ -0,0 +1,80 @@
1+# WP35 — Measurement report (Phase 3)
2+
3+All numbers come from `results/empirical/measurement_diagnostics.json`, `measurement_diagnostics_modern.json`,
4+`measurement_irt_item_parameters.csv`, `power_h2a_*.csv`, produced by `python -m wp35.measurement.build_indices`
5+(2026-11-01). **No outcome variable was read by that module.** Definitions follow `research/preregistration.md` §3.
6+
7+## 1. Seshat (polity level)
8+
9+### 1.1 Coordination demand D
10+Four logged scale variables (polity population, territory, largest-settlement population; settlement hierarchy levels
11+unlogged). First principal component of their pairwise-complete correlation matrix explains **82.1%** of variance with
12+near-equal loadings (log pop 0.26, log territory 0.24, log capital 0.26, settlement hierarchy 0.24). Score = loading-
13+weighted mean of available standardized variables, requiring ≥ 2 of 4; **442 polities** have `D_pc1`; 299 have
14+population alone (`log_pop`). Range bounds (`value_from`/`value_to`) propagate to `D_pc1_lo/hi`: the median half-width
15+is 0.017 SD, the 90th percentile 0.093 SD, so range uncertainty in the scale variables is small relative to
16+between-polity variation (the larger uncertainty is *which* polities have any estimate at all).
17+
18+### 1.2 Coordination capacity K
19+Four blocks of presence indicators, each the share coded present among coded items (≥ 50% coded required):
20+administration (5 items incl. rank-scaled administrative levels), standardized rules and money (10), communication
21+infrastructure (7), record-keeping (9). `K_mean` = mean of blocks with ≥ 3 blocks: **344 polities**. Inter-block
22+correlations 0.75–0.79; first PC of the blocks explains 82.4% (loadings 0.25 each), and `K_pc1` correlates 0.999 with
23+`K_mean`. Cronbach α of the four blocks by macro-region ranges 0.83 (Europe) to 0.97 (South Asia); mean off-diagonal
24+block correlation by era 0.67 (1001–1500) to 0.81 (pre-1000 BCE) — the four capacity blocks hang together
25+in every region and era.
26+Latent alternative: 2PL IRT (MAP, log-normal prior on discrimination, EM converged in 135 iterations) on the 30 binary
27+items (disputed 0.5 → missing; administrative levels excluded) gives `K_irt` for **579 polities** (≥ 3 items). It
28+correlates **0.96** with `K_mean`. Item difficulties order sensibly (tokens, non-written records, roads, couriers easy;
29+paper currency, general postal service, examination system, merit promotion hard; `article` is nearly universal and
30+uninformative). Five record-keeping items (written record, script, lists/tables, calendar, practical literature) hit the
31+discrimination ceiling (8): they are coded almost jointly in Seshat, i.e. locally dependent; the latent score is
32+therefore dominated by the "writing cluster", which is why we keep the transparent block mean as primary. Posterior SD of
33+`K_irt`: median 0.34, 90th percentile 0.61 (on a unit-variance scale) — individual-polity capacity scores carry
34+substantial uncertainty; this is carried into the econometrics through the IRT variant and the bound runs.
35+Cross-region invariance: refitting the IRT separately on the Old World (n = 532) and on the Americas + Southeast
36+Asia/Oceania (n = 94) gives item difficulties correlated **0.88** across the two fits (mean absolute difference 1.16
37+logits, inflated by boundary estimates on the small New-World sample). Comparability is acceptable for the ordering
38+of items but the New-World sample is too small for a strict invariance test; leave-region-out results (H5) are the
39+operational safeguard.
40+Leave-one-block-out: dropping any block leaves `K` correlated ≥ 0.98 with the full index and `P` correlated ≥ 0.95
41+with `P_main`.
42+
43+### 1.3 Coordination pressure P and the collinearity problem
44+`P_main = z(D_pc1) − z(K_mean)` is defined for **317 polities** and, in pre-crisis form, for **127 of the 166 CrisisDB
45+records**. As anticipated from Turchin et al. (2018) and Shin et al. (2020), scale and capacity are strongly
46+collinear: corr(`D_pc1`, `K_mean`) = **0.84** (0.80 with `K_irt`). Consequently `P_main` retains only **16.6%** of the
47+variance of z(D)+z(K); it correlates 0.39 with D and −0.18 with K, and −0.04 with the Turchin-style single complexity
48+axis (`complexity_pc1`, first PC of 57 social-complexity indicators, 49.7% of variance). The mismatch index is thus
49+nearly orthogonal to overall complexity, which is exactly what makes the H2 test informative *if* P carries signal, and
50+what makes it fragile: P is a residual, so measurement error in either D or K leaks into it. The pre-registered
51+baselines (D-only, K-only, complexity-PC1-only) address this directly.
52+
53+### 1.4 A-priori power (pre-registration §5.8; random outcomes, actual P distribution, n = 127)
54+| Outcome | β (per SD of P, logit) | power |
55+|---|---|---|
56+| Y1b binary (base rate 0.77) | 0.4 / 0.5 / 0.6 / 0.8 | 0.47 / 0.63 / 0.79 / 0.95 |
57+| Y1 fractional (14 items, base 0.29) | 0.10 / 0.15 / 0.20 | 0.49 / 0.81 / 0.96 |
58+**Decision (pre-registered rule):** the binary severe-outcome test Y1b is *underpowered a priori* (MDE ≈ 0.6 > 0.5);
59+the fractional severity Y1 is adequately powered for effects of ≈ 0.15 log-odds per SD. Y1 remains the primary
60+Seshat H2 outcome; Y1b is reported as secondary.
61+
62+## 2. Modern panel
63+`K_modern` = mean of six standardized V-Dem items (≥ 4 coded): rigorous/impartial administration, meritocratic
64+appointment, transparent laws, fiscal capacity source, territorial control, EMB capacity; PC1 explains 60.5% with
65+loadings 0.13–0.19. `D_modern` = log Maddison population (1820+); `P_modern` defined for **15,697 country-years**.
66+corr(D, K) = 0.06 (no collinearity problem in the modern panel). **Caveat fixed before analysis:** corr(`K_modern`,
67+`v2x_polyarchy`) = **0.75** — the V-Dem capacity items are strongly associated with electoral democracy even though no
68+democracy index enters K; all modern specifications therefore control for polyarchy as pre-registered, and the K-only
69+baseline is reported.
70+
71+## 3. Ethnographic Atlas
72+`log_community_size` from EA031 category midpoints (611 societies), `log_population` from continuous EA202 (953),
73+`jurisdictional_hierarchy_beyond` = EA033 − 1 (0–4 levels; 1,155). No outcome; used for H1 only, with Glottolog
74+family (173 families) and region controls.
75+
76+## 4. What the measurement model cannot do
77+- Seshat K items are presence codes: they measure *existence*, not reach or quality, of institutions.
78+- Most facts are undated, so "pre-crisis" K is mostly whole-polity K (see pre-registration §3.6).
79+- No direct indicator of division of labour or dispersion in Polaris; D is scale only.
80+- IRT local dependence among writing items; invariance only coarsely checked.
added results/empirical/measurement_diagnostics.json +321 −0
@@ -0,0 +1,321 @@
1+{
2+ "D_pc1_loadings": {
3+ "log_pop": 0.2646,
4+ "log_terr": 0.2366,
5+ "log_cap": 0.2617,
6+ "settl_hier": 0.2371
7+ },
8+ "D_pc1_var_share": 0.8209,
9+ "K_pc1_loadings": {
10+ "K_admin": 0.2481,
11+ "K_rules_money": 0.2505,
12+ "K_comm_infra": 0.2526,
13+ "K_records": 0.2488
14+ },
15+ "K_pc1_var_share": 0.8239,
16+ "complexity_pc1_var_share": 0.497,
17+ "complexity_pc1_n_vars": 57,
18+ "irt_loglik": -3345.5533228338218,
19+ "irt_iterations": 135,
20+ "n_D_pc1": 442,
21+ "n_K_mean": 344,
22+ "n_K_irt": 579,
23+ "n_P_main": 317,
24+ "n_P_main_crisis": 127,
25+ "corr_zD_zK": {
26+ "D_pc1~K_mean": 0.8384,
27+ "D_pc1~K_irt": 0.7998,
28+ "log_pop~K_mean": 0.7966,
29+ "K_mean~K_irt": 0.9625,
30+ "K_mean~K_pc1": 0.9994,
31+ "P_main~complexity_pc1": -0.0434,
32+ "P_main~D_pc1": 0.3855,
33+ "P_main~K_mean": -0.1797
34+ },
35+ "P_variance_share_of_D_plus_K": 0.1655,
36+ "block_correlations": {
37+ "K_admin": {
38+ "K_admin": 1.0,
39+ "K_rules_money": 0.755,
40+ "K_comm_infra": 0.766,
41+ "K_records": 0.751
42+ },
43+ "K_rules_money": {
44+ "K_admin": 0.755,
45+ "K_rules_money": 1.0,
46+ "K_comm_infra": 0.789,
47+ "K_records": 0.757
48+ },
49+ "K_comm_infra": {
50+ "K_admin": 0.766,
51+ "K_rules_money": 0.789,
52+ "K_comm_infra": 1.0,
53+ "K_records": 0.773
54+ },
55+ "K_records": {
56+ "K_admin": 0.751,
57+ "K_rules_money": 0.757,
58+ "K_comm_infra": 0.773,
59+ "K_records": 1.0
60+ }
61+ },
62+ "block_alpha_by_macro_region": {
63+ "Central_Asia": {
64+ "n": 17,
65+ "alpha": 0.933
66+ },
67+ "East_Asia": {
68+ "n": 35,
69+ "alpha": 0.964
70+ },
71+ "Europe": {
72+ "n": 57,
73+ "alpha": 0.831
74+ },
75+ "North_Africa": {
76+ "n": 27,
77+ "alpha": 0.886
78+ },
79+ "North_America": {
80+ "n": 19,
81+ "alpha": 0.936
82+ },
83+ "South_Asia": {
84+ "n": 11,
85+ "alpha": 0.968
86+ },
87+ "Southeast_Asia_Oceania": {
88+ "n": 19,
89+ "alpha": 0.935
90+ },
91+ "Southwest_Asia": {
92+ "n": 39,
93+ "alpha": 0.88
94+ }
95+ },
96+ "block_corr_mean_by_era": {
97+ "1-1000": {
98+ "n": 46,
99+ "mean_offdiag_corr": 0.726
100+ },
101+ "1000BCE-0": {
102+ "n": 35,
103+ "mean_offdiag_corr": 0.756
104+ },
105+ "1001-1500": {
106+ "n": 51,
107+ "mean_offdiag_corr": 0.666
108+ },
109+ "1501-1800": {
110+ "n": 38,
111+ "mean_offdiag_corr": 0.785
112+ },
113+ "post-1800": {
114+ "n": 20,
115+ "mean_offdiag_corr": 0.765
116+ },
117+ "pre-1000BCE": {
118+ "n": 43,
119+ "mean_offdiag_corr": 0.805
120+ }
121+ },
122+ "measurement_error": {
123+ "D_pc1_halfwidth_median": 0.016672931644356626,
124+ "D_pc1_halfwidth_q90": 0.09309851215013848,
125+ "K_irt_psd_median": 0.34213753777518097,
126+ "K_irt_psd_q90": 0.6062046010250374,
127+ "sd_K_irt": 1.008347206077521,
128+ "P_main_range_median": 0.036226778179016206
129+ },
130+ "irt_invariance": {
131+ "items_compared": 30,
132+ "corr_difficulty_old_vs_new": 0.88,
133+ "mean_abs_diff_difficulty": 1.162,
134+ "items": [
135+ "full_time_bureaucrat",
136+ "specialized_government_building",
137+ "merit_promotion",
138+ "examination_system",
139+ "formal_legal_code",
140+ "judge",
141+ "court",
142+ "professional_lawyer",
143+ "indigenous_coin",
144+ "foreign_coin",
145+ "paper_currency",
146+ "token",
147+ "precious_metal",
148+ "article",
149+ "courier",
150+ "postal_station",
151+ "general_postal_service",
152+ "road",
153+ "bridge",
154+ "canal",
155+ "port",
156+ "written_record",
157+ "script",
158+ "phonetic_alphabetic_writing",
159+ "non_phonetic_writing",
160+ "lists_tables_and_classification",
161+ "calendar",
162+ "practical_literature",
163+ "nonwritten_record",
164+ "mnemonic_device"
165+ ],
166+ "fits": {
167+ "old_world": {
168+ "n": 525,
169+ "b": [
170+ -0.567,
171+ -0.598,
172+ 0.554,
173+ 1.04,
174+ -0.659,
175+ -0.089,
176+ -0.159,
177+ 0.605,
178+ -0.041,
179+ -0.287,
180+ 2.037,
181+ -1.102,
182+ -1.05,
183+ -3.99,
184+ -0.984,
185+ 0.164,
186+ 1.134,
187+ -1.121,
188+ -0.949,
189+ -0.328,
190+ -0.901,
191+ -0.736,
192+ -0.845,
193+ -0.409,
194+ 1.716,
195+ -0.673,
196+ -0.669,
197+ -0.498,
198+ -3.397,
199+ -1.374
200+ ],
201+ "a": [
202+ 3.44,
203+ 3.543,
204+ 1.905,
205+ 3.328,
206+ 2.428,
207+ 2.984,
208+ 2.47,
209+ 2.412,
210+ 3.564,
211+ 1.758,
212+ 1.82,
213+ 0.646,
214+ 2.036,
215+ 0.626,
216+ 2.456,
217+ 2.305,
218+ 1.695,
219+ 3.24,
220+ 2.977,
221+ 2.077,
222+ 1.802,
223+ 8.0,
224+ 8.0,
225+ 1.644,
226+ 0.822,
227+ 8.0,
228+ 8.0,
229+ 6.448,
230+ 1.202,
231+ 1.277
232+ ]
233+ },
234+ "new_world_oceania": {
235+ "n": 94,
236+ "b": [
237+ 0.128,
238+ 0.085,
239+ 2.663,
240+ 3.537,
241+ 0.354,
242+ 0.801,
243+ 0.728,
244+ 1.073,
245+ 1.881,
246+ 1.071,
247+ 2.593,
248+ 1.207,
249+ 0.757,
250+ -3.221,
251+ 0.518,
252+ 1.58,
253+ 1.786,
254+ 0.072,
255+ 0.07,
256+ 0.565,
257+ 0.578,
258+ 0.159,
259+ 0.004,
260+ 0.736,
261+ 1.473,
262+ 0.642,
263+ -0.049,
264+ 0.922,
265+ -1.575,
266+ -0.93
267+ ],
268+ "a": [
269+ 3.407,
270+ 1.466,
271+ 0.849,
272+ 1.541,
273+ 3.221,
274+ 2.923,
275+ 3.622,
276+ 3.145,
277+ 1.934,
278+ 1.517,
279+ 1.11,
280+ 0.467,
281+ 1.44,
282+ 0.454,
283+ 1.585,
284+ 1.687,
285+ 1.946,
286+ 1.387,
287+ 1.46,
288+ 1.677,
289+ 2.691,
290+ 2.461,
291+ 2.748,
292+ 3.894,
293+ 0.629,
294+ 2.858,
295+ 3.09,
296+ 3.732,
297+ 0.826,
298+ 0.696
299+ ]
300+ }
301+ }
302+ },
303+ "leave_one_block_out": {
304+ "admin": {
305+ "corr_K_with_full": 0.9858,
306+ "corr_P_with_main": 0.9575
307+ },
308+ "rules_money": {
309+ "corr_K_with_full": 0.9877,
310+ "corr_P_with_main": 0.9615
311+ },
312+ "comm_infra": {
313+ "corr_K_with_full": 0.9874,
314+ "corr_P_with_main": 0.9657
315+ },
316+ "records": {
317+ "corr_K_with_full": 0.9816,
318+ "corr_P_with_main": 0.9474
319+ }
320+ }
321+}
\ No newline at end of file
added results/empirical/measurement_diagnostics_modern.json +72 −0
@@ -0,0 +1,72 @@
1+{
2+ "K_modern_pc1_loadings": {
3+ "v2clrspct": 0.1859,
4+ "v2stcritrecadm": 0.1678,
5+ "v2cltrnslw": 0.1906,
6+ "v2stfisccap": 0.15,
7+ "v2svstterr": 0.1337,
8+ "v2elembcap": 0.1721
9+ },
10+ "K_modern_pc1_var_share": 0.6052,
11+ "n_P_modern": 15697,
12+ "corr_D_K_modern": 0.0624,
13+ "corr_K_modern_polyarchy": 0.7541,
14+ "item_corr": {
15+ "v2clrspct": {
16+ "v2clrspct": 1.0,
17+ "v2stcritrecadm": 0.644,
18+ "v2cltrnslw": 0.813,
19+ "v2stfisccap": 0.473,
20+ "v2svstterr": 0.422,
21+ "v2elembcap": 0.574
22+ },
23+ "v2stcritrecadm": {
24+ "v2clrspct": 0.644,
25+ "v2stcritrecadm": 1.0,
26+ "v2cltrnslw": 0.621,
27+ "v2stfisccap": 0.438,
28+ "v2svstterr": 0.359,
29+ "v2elembcap": 0.521
30+ },
31+ "v2cltrnslw": {
32+ "v2clrspct": 0.813,
33+ "v2stcritrecadm": 0.621,
34+ "v2cltrnslw": 1.0,
35+ "v2stfisccap": 0.513,
36+ "v2svstterr": 0.424,
37+ "v2elembcap": 0.654
38+ },
39+ "v2stfisccap": {
40+ "v2clrspct": 0.473,
41+ "v2stcritrecadm": 0.438,
42+ "v2cltrnslw": 0.513,
43+ "v2stfisccap": 1.0,
44+ "v2svstterr": 0.374,
45+ "v2elembcap": 0.497
46+ },
47+ "v2svstterr": {
48+ "v2clrspct": 0.422,
49+ "v2stcritrecadm": 0.359,
50+ "v2cltrnslw": 0.424,
51+ "v2stfisccap": 0.374,
52+ "v2svstterr": 1.0,
53+ "v2elembcap": 0.443
54+ },
55+ "v2elembcap": {
56+ "v2clrspct": 0.574,
57+ "v2stcritrecadm": 0.521,
58+ "v2cltrnslw": 0.654,
59+ "v2stfisccap": 0.497,
60+ "v2svstterr": 0.443,
61+ "v2elembcap": 1.0
62+ }
63+ },
64+ "corr_K_by_region": {
65+ "1": -0.085,
66+ "2": 0.259,
67+ "3": 0.081,
68+ "4": 0.041,
69+ "5": -0.045,
70+ "6": -0.012
71+ }
72+}
\ No newline at end of file
added results/empirical/measurement_irt_item_parameters.csv +31 −0
@@ -0,0 +1,31 @@
1+item,a,b,block
2+full_time_bureaucrat,3.6527424079806603,-0.41859686853078504,admin
3+specialized_government_building,3.066730654084431,-0.5085373165354822,admin
4+merit_promotion,1.7802655820815738,0.7021586341368805,admin
5+examination_system,3.3869433671617886,1.1375006596571404,admin
6+formal_legal_code,2.9525895205555868,-0.4141059941613492,rules_money
7+judge,3.552224718836929,0.060069448676890166,rules_money
8+court,3.0760342165002372,0.008468451933063378,rules_money
9+professional_lawyer,2.72592841935458,0.6535317471947827,rules_money
10+indigenous_coin,3.7072105660727472,0.15112395390732436,rules_money
11+foreign_coin,1.9763009083036096,-0.08042415203888174,rules_money
12+paper_currency,1.4853538625942566,2.2492864209104257,rules_money
13+token,0.579698340872784,-0.8294519474758425,rules_money
14+precious_metal,2.1558974591242315,-0.6735737580810314,rules_money
15+article,0.5286046257791284,-4.364025532653697,rules_money
16+courier,2.670690519140207,-0.6724574117368375,comm_infra
17+postal_station,2.560978399199805,0.3210643907483727,comm_infra
18+general_postal_service,1.9077225274480532,1.1474143346185122,comm_infra
19+road,2.8878850939209206,-0.8863284670864775,comm_infra
20+bridge,2.7265013330964507,-0.7485140494607205,comm_infra
21+canal,2.165331667573359,-0.1798629790826995,comm_infra
22+port,2.2796348701360767,-0.5152583130598091,comm_infra
23+written_record,8.0,-0.5885046260962389,records
24+script,7.974647676175637,-0.6926954931493575,records
25+phonetic_alphabetic_writing,2.1001761814441022,-0.1634518168751169,records
26+non_phonetic_writing,0.6965894707957191,1.9288626298500053,records
27+lists_tables_and_classification,7.643776344260694,-0.4587993518664177,records
28+calendar,8.0,-0.5777488445589021,records
29+practical_literature,8.0,-0.2566162216282787,records
30+nonwritten_record,1.2026821075448688,-2.671075035463131,records
31+mnemonic_device,1.038487055773755,-1.398153218842954,records
added results/empirical/power_h2a_binary.csv +8 −0
@@ -0,0 +1,8 @@
1+beta,power,n
2+0.2,0.1875,127
3+0.3,0.31,127
4+0.4,0.47,127
5+0.5,0.625,127
6+0.6,0.785,127
7+0.8,0.945,127
8+1.0,0.99,127
added results/empirical/power_h2a_fractional.csv +7 −0
@@ -0,0 +1,7 @@
1+beta,power,n
2+0.1,0.4925,127
3+0.15,0.805,127
4+0.2,0.9625,127
5+0.3,1.0,127
6+0.4,1.0,127
7+0.5,1.0,127
modified results/logs/execution_log.md +3 −0
@@ -17,3 +17,6 @@
17 | 2026-11-01 03:55 | 2 | Glottolog 5.3 languages.csv registered + fetched (language families for Galton controls) | registry 9 sources | 17 | 2026-11-01 03:55 | 2 | Glottolog 5.3 languages.csv registered + fetched (language families for Galton controls) | registry 9 sources |
18 | 2026-11-01 04:10 | 2 | `python -m wp35.harmonization.build_panels` (3 runs: EA033 code parsing fixed via `ord`; EA202 continuous; Y6 redefined as last regime year with irregular end type after inspecting France 1789–1960) | `data/processed/*.parquet`, `results/diagnostics/*`, `paper/figures/fig_seshat_temporal_coverage.pdf`, `fig_seshat_missingness.pdf`, `fig_dplace_focal_years.pdf`, `fig_modern_coverage.pdf`, `coverage_summary.json` | 18 | 2026-11-01 04:10 | 2 | `python -m wp35.harmonization.build_panels` (3 runs: EA033 code parsing fixed via `ord`; EA202 continuous; Y6 redefined as last regime year with irregular end type after inspecting France 1789–1960) | `data/processed/*.parquet`, `results/diagnostics/*`, `paper/figures/fig_seshat_temporal_coverage.pdf`, `fig_seshat_missingness.pdf`, `fig_dplace_focal_years.pdf`, `fig_modern_coverage.pdf`, `coverage_summary.json` |
19 | 2026-11-01 04:15 | 2 | Data dictionary + parser tests (`pytest`: 6 passed) | `data/metadata/data_dictionary.md`, `tests/test_harmonization.py` | 19 | 2026-11-01 04:15 | 2 | Data dictionary + parser tests (`pytest`: 6 passed) | `data/metadata/data_dictionary.md`, `tests/test_harmonization.py` |
20+| 2026-11-01 04:40 | 3 | Formal framework drafted (fork agent) and reviewed: Theorems 1–5 with proofs, Conjectures C1–C3, mapping to tests; Lindley 1952 / Loynes 1962 verified by Crossref and added | `research/framework.md` |
21+| 2026-11-01 04:50 | 3 | `python -m wp35.measurement.build_indices` (2 runs; IRT regularized with MAP priors after boundary estimates) | `data/processed/seshat_indices.parquet`, `seshat_crisis_indices.parquet`, `modern_indices.parquet`; `results/empirical/measurement_*`, `power_h2a_*.csv`; figures `fig_measurement_DK.pdf`, `fig_irt_invariance.pdf` |
22+| 2026-11-01 05:00 | 3 | Measurement report: corr(D,K)=0.84, P keeps 16.6% of variance; MDE Y1b≈0.6 (underpowered), Y1≈0.15 | `research/measurement.md` |
added src/wp35/measurement/build_indices.py +288 −0
@@ -0,0 +1,288 @@
1+"""Phase 3 measurement: build D, K, P (and rival covariates) exactly as pre-registered, with alternatives,
2+measurement error, cross-region comparability diagnostics and an a-priori power computation.
3+Usage: python -m wp35.measurement.build_indices
4+No outcome (Y1, Y2, Y3, Y4-6) is read or used anywhere in this module.
5+"""
6+from __future__ import annotations
7+import json
8+from pathlib import Path
9+import numpy as np
10+import pandas as pd
11+import matplotlib
12+matplotlib.use("Agg")
13+import matplotlib.pyplot as plt
14+import statsmodels.api as sm
15+from wp35.measurement.irt import fit_2pl
16+from wp35.harmonization.build_panels import K_BLOCKS, STANDARDS, D_VARS, PROD_INFRA, MILITARY
17+
18+ROOT = Path(__file__).resolve().parents[3]
19+PROC = ROOT / "data" / "processed"; RES = ROOT / "results" / "empirical"; FIG = ROOT / "paper" / "figures"
20+RES.mkdir(parents=True, exist_ok=True); FIG.mkdir(parents=True, exist_ok=True)
21+SEED = 20261101
22+K_ITEMS_MODERN = ["v2clrspct", "v2stcritrecadm", "v2cltrnslw", "v2stfisccap", "v2svstterr", "v2elembcap"]
23+BIN_K = [v for b in K_BLOCKS.values() for v in b if v != "administrative_level"]
24+
25+
26+def z(x: pd.Series, ref: pd.Series | None = None) -> pd.Series:
27+ ref = x if ref is None else ref
28+ return (x - ref.mean()) / ref.std(ddof=0)
29+
30+
31+def pc1_pairwise(df: pd.DataFrame, min_coded: int = 2):
32+ """First principal component of the pairwise-complete correlation matrix; scores = loading-weighted mean of the
33+ available standardized variables (requires >= min_coded coded). Loadings signed so that the sum is positive."""
34+ Zs = (df - df.mean()) / df.std(ddof=0)
35+ C = Zs.corr(min_periods=10).fillna(0).values
36+ w, V = np.linalg.eigh(C); v = V[:, -1]; v = v if v.sum() > 0 else -v
37+ load = pd.Series(v / np.abs(v).sum(), index=df.columns)
38+ avail = Zs.notna()
39+ num = (Zs.fillna(0) * load).sum(axis=1); den = (avail * load.abs()).sum(axis=1)
40+ score = num / den.where(den > 0)
41+ score[avail.sum(axis=1) < min_coded] = np.nan
42+ return score, load, float(w[-1] / w.sum())
43+
44+
45+def block_score(wide: pd.DataFrame, items: list[str], min_share: float = 0.5) -> pd.Series:
46+ sub = wide.reindex(columns=items).copy()
47+ if "administrative_level" in sub.columns:
48+ r = sub["administrative_level"].rank(method="average")
49+ sub["administrative_level"] = (r - 1) / (r.max() - 1)
50+ coded = sub.notna().mean(axis=1)
51+ return sub.mean(axis=1).where(coded >= min_share)
52+
53+
54+def build_seshat():
55+ wide = pd.read_parquet(PROC / "seshat_polity_wide.parquet")
56+ agg = pd.read_parquet(PROC / "seshat_polity_agg.parquet")
57+ meta = pd.read_parquet(PROC / "seshat_polity_meta.parquet").set_index("polity_id")
58+ logw = agg.pivot(index="polity_id", columns="variable", values="logv_mean")
59+ lo = agg.pivot(index="polity_id", columns="variable", values="lo_min"); hi = agg.pivot(index="polity_id", columns="variable", values="hi_max")
60+ out = pd.DataFrame(index=wide.index)
61+ # ---- D ----
62+ Dmat = pd.DataFrame({"log_pop": logw["polity_population"], "log_terr": logw["polity_territory"],
63+ "log_cap": logw["population_of_the_largest_settlement"], "settl_hier": wide["settlement_hierarchy"]})
64+ out["D_pc1"], D_load, D_var = pc1_pairwise(Dmat)
65+ out["log_pop"] = Dmat.log_pop; out["log_terr"] = Dmat.log_terr; out["log_cap"] = Dmat.log_cap; out["settl_hier"] = Dmat.settl_hier
66+ # bounds for measurement-error sensitivity (geometric variables only)
67+ with np.errstate(divide="ignore"):
68+ out["log_pop_lo"] = np.log(lo["polity_population"].where(lo["polity_population"] > 0)); out["log_pop_hi"] = np.log(hi["polity_population"].where(hi["polity_population"] > 0))
69+ out["log_terr_lo"] = np.log(lo["polity_territory"].where(lo["polity_territory"] > 0)); out["log_terr_hi"] = np.log(hi["polity_territory"].where(hi["polity_territory"] > 0))
70+ # ---- K blocks ----
71+ for b, items in K_BLOCKS.items():
72+ out[f"K_{b}"] = block_score(wide, items)
73+ blocks = out[[f"K_{b}" for b in K_BLOCKS]]
74+ out["K_mean"] = blocks.mean(axis=1).where(blocks.notna().sum(axis=1) >= 3)
75+ out["K_n_blocks"] = blocks.notna().sum(axis=1)
76+ out["K_pc1"], K_load, K_var = pc1_pairwise(blocks, min_coded=3)
77+ # IRT on binary items (disputed 0.5 -> missing)
78+ Xb = wide.reindex(columns=BIN_K).copy(); Xb = Xb.where(Xb != 0.5)
79+ irt = fit_2pl(Xb.values)
80+ out["K_irt"] = irt["eap"]; out["K_irt_psd"] = irt["psd"]; out["K_irt_nitems"] = irt["n_items"]
81+ item_params = pd.DataFrame({"item": BIN_K, "a": irt["a"], "b": irt["b"], "block": [next(k for k, v in K_BLOCKS.items() if i in v) for i in BIN_K]})
82+ item_params.to_csv(RES / "measurement_irt_item_parameters.csv", index=False)
83+ # standards sub-index (sparse; latent only)
84+ out["K_standards"] = block_score(wide, STANDARDS)
85+ # ---- P ----
86+ for dname in ["D_pc1", "log_pop"]:
87+ for kname in ["K_mean", "K_irt", "K_pc1"]:
88+ out[f"P_{dname}_{kname}"] = z(out[dname]) - z(out[kname])
89+ out["P_main"] = out["P_D_pc1_K_mean"]
90+ # standardization windows (sensitivity)
91+ df = out.join(meta[["macro_region", "era"]])
92+ out["P_within_era"] = df.groupby("era").D_pc1.transform(z) - df.groupby("era").K_mean.transform(z)
93+ out["P_within_region"] = df.groupby("macro_region").D_pc1.transform(z) - df.groupby("macro_region").K_mean.transform(z)
94+ out["P_rank"] = out.D_pc1.rank(pct=True) - out.K_mean.rank(pct=True)
95+ # bounds
96+ Dlo = pd.DataFrame({"log_pop": out.log_pop_lo, "log_terr": out.log_terr_lo, "log_cap": Dmat.log_cap, "settl_hier": Dmat.settl_hier})
97+ Dhi = pd.DataFrame({"log_pop": out.log_pop_hi, "log_terr": out.log_terr_hi, "log_cap": Dmat.log_cap, "settl_hier": Dmat.settl_hier})
98+ out["D_pc1_lo"] = ((Dlo - Dmat.mean()) / Dmat.std(ddof=0)).fillna(0).mul(D_load).sum(axis=1) / (Dlo.notna() * D_load.abs()).sum(axis=1).replace(0, np.nan)
99+ out["D_pc1_hi"] = ((Dhi - Dmat.mean()) / Dmat.std(ddof=0)).fillna(0).mul(D_load).sum(axis=1) / (Dhi.notna() * D_load.abs()).sum(axis=1).replace(0, np.nan)
100+ out.loc[out.D_pc1.isna(), ["D_pc1_lo", "D_pc1_hi"]] = np.nan
101+ out["P_main_lo"] = z(out.D_pc1_lo, out.D_pc1) - z(out.K_mean); out["P_main_hi"] = z(out.D_pc1_hi, out.D_pc1) - z(out.K_mean)
102+ # drop inferred codes sensitivity: recompute K_mean using only non-inferred facts
103+ facts = pd.read_parquet(PROC / "seshat_facts.parquet")
104+ ni = facts[(~facts.inferred) & facts.value.notna()].groupby(["polity_id", "variable"]).value.mean().unstack()
105+ out["K_mean_noinf"] = pd.concat([block_score(ni, it) for it in K_BLOCKS.values()], axis=1).pipe(lambda b: b.mean(axis=1).where(b.notna().sum(axis=1) >= 3)).reindex(out.index)
106+ out["P_main_noinf"] = z(out.D_pc1) - z(out.K_mean_noinf)
107+ # ---- rival covariates (excluded from K by design) ----
108+ wf = facts[facts.sheet == "Warfare"].groupby(["polity_id", "variable"]).value.mean().unstack()
109+ out["X_warfare"] = wf.mean(axis=1).where(wf.notna().mean(axis=1) >= 0.5).reindex(out.index)
110+ out["X_prod_infra"] = block_score(wide, PROD_INFRA)
111+ mil = wide.reindex(columns=MILITARY).copy(); r = mil["military_level"].rank(); mil["military_level"] = (r - 1) / (r.max() - 1)
112+ out["X_military"] = mil.mean(axis=1).where(mil.notna().mean(axis=1) >= 0.5)
113+ out["X_moralizing"] = wide.get("moralizing_supernatural_punishment_and_reward")
114+ rt = facts[facts.sheet == "Religion"].groupby(["polity_id", "variable"]).value.mean().unstack()
115+ out["X_moralizing"] = rt.get("moralizing_supernatural_punishment_and_reward").reindex(out.index) if "moralizing_supernatural_punishment_and_reward" in rt else np.nan
116+ # Turchin-style single complexity axis over ALL social-complexity indicators (baseline B3)
117+ sc_vars = [v for v in facts[facts.sheet == "Social complexity"].variable.unique() if v in wide.columns]
118+ scmat = wide[sc_vars].copy()
119+ for v in ["polity_population", "polity_territory", "population_of_the_largest_settlement"]:
120+ scmat[v] = logw[v]
121+ scmat = scmat.loc[:, scmat.notna().sum() >= 100]
122+ out["complexity_pc1"], C_load, C_var = pc1_pairwise(scmat, min_coded=5)
123+ out = out.join(meta[["seshat_region", "homeNGA", "macro_region", "era", "start_year", "end_year", "midpoint", "duration", "censored", "centralization", "utm_zone"]])
124+ out.index.name = "polity_id"
125+ out.reset_index().to_parquet(PROC / "seshat_indices.parquet", index=False)
126+ # ---- pre-crisis versions (same loadings/standardization as the full sample) ----
127+ pre = pd.read_parquet(PROC / "seshat_precrisis_agg.parquet")
128+ pw = pre.pivot(index="crisis_id", columns="variable", values="value_mean"); plog = pre.pivot(index="crisis_id", columns="variable", values="logv_mean")
129+ cr = pd.DataFrame(index=pw.index)
130+ Dp = pd.DataFrame({"log_pop": plog.get("polity_population"), "log_terr": plog.get("polity_territory"), "log_cap": plog.get("population_of_the_largest_settlement"), "settl_hier": pw.get("settlement_hierarchy")}, index=pw.index)
131+ Zp = (Dp - Dmat.mean()) / Dmat.std(ddof=0)
132+ cr["D_pc1"] = (Zp.fillna(0) * D_load).sum(axis=1) / (Zp.notna() * D_load.abs()).sum(axis=1).replace(0, np.nan)
133+ cr.loc[Zp.notna().sum(axis=1) < 2, "D_pc1"] = np.nan
134+ cr["log_pop"] = Dp.log_pop
135+ for b, items in K_BLOCKS.items():
136+ cr[f"K_{b}"] = block_score(pw, items)
137+ bl = cr[[f"K_{b}" for b in K_BLOCKS]]; cr["K_mean"] = bl.mean(axis=1).where(bl.notna().sum(axis=1) >= 3)
138+ Xp = pw.reindex(columns=BIN_K).where(lambda d: d != 0.5)
139+ # EAP with the full-sample item parameters (no refit): posterior under fixed a, b
140+ nodes, weights = np.polynomial.hermite_e.hermegauss(41); weights /= weights.sum()
141+ from scipy.special import expit
142+ Pq = expit(irt["a"][None, :] * (nodes[:, None] - irt["b"][None, :]))
143+ Xv = Xp.values; obs = ~np.isnan(Xv); Xz_ = np.where(obs, Xv, 0)
144+ L = Xz_ @ np.log(Pq + 1e-300).T + ((1 - Xz_) * obs) @ np.log(1 - Pq + 1e-300).T + np.log(weights)[None, :]
145+ post = np.exp(L - L.max(axis=1, keepdims=True)); post /= post.sum(axis=1, keepdims=True)
146+ cr["K_irt"] = np.where(obs.sum(axis=1) >= 3, post @ nodes, np.nan)
147+ cr["K_pc1"] = (((bl - blocks.mean()) / blocks.std(ddof=0)).fillna(0) * K_load).sum(axis=1) / (bl.notna() * K_load.abs()).sum(axis=1).replace(0, np.nan)
148+ cr.loc[bl.notna().sum(axis=1) < 3, "K_pc1"] = np.nan
149+ for dname in ["D_pc1", "log_pop"]:
150+ for kname in ["K_mean", "K_irt", "K_pc1"]:
151+ cr[f"P_{dname}_{kname}"] = z(cr[dname], out[dname]) - z(cr[kname], out[kname])
152+ cr["P_main"] = cr["P_D_pc1_K_mean"]
153+ wfp = pre[pre.variable.isin(wf.columns)].pivot(index="crisis_id", columns="variable", values="value_mean")
154+ cr["X_warfare"] = wfp.mean(axis=1).where(wfp.notna().mean(axis=1) >= 0.5).reindex(cr.index)
155+ cr["X_prod_infra"] = block_score(pw, PROD_INFRA)
156+ milp = pw.reindex(columns=MILITARY).copy(); milp["military_level"] = milp["military_level"].rank().pipe(lambda r: (r - 1) / (r.max() - 1)); cr["X_military"] = milp.mean(axis=1).where(milp.notna().mean(axis=1) >= 0.5)
157+ scp = pw.reindex(columns=scmat.columns).copy()
158+ for v in ["polity_population", "polity_territory", "population_of_the_largest_settlement"]:
159+ scp[v] = plog.get(v)
160+ Zc = (scp - scmat.mean()) / scmat.std(ddof=0)
161+ cr["complexity_pc1"] = (Zc.fillna(0) * C_load).sum(axis=1) / (Zc.notna() * C_load.abs()).sum(axis=1).replace(0, np.nan)
162+ cr.loc[Zc.notna().sum(axis=1) < 5, "complexity_pc1"] = np.nan
163+ crisis = pd.read_parquet(PROC / "seshat_crisis.parquet").set_index("crisis_id")
164+ cr["polity_id"] = crisis.polity_id; cr["crisis_start"] = crisis.crisis_start
165+ cr = cr.join(meta[["macro_region", "era", "seshat_region", "homeNGA", "start_year"]], on="polity_id")
166+ cr["years_since_start"] = cr.crisis_start - cr.start_year
167+ cr.reset_index().to_parquet(PROC / "seshat_crisis_indices.parquet", index=False)
168+ # ---- diagnostics: collinearity, comparability, measurement error ----
169+ diag = {"D_pc1_loadings": D_load.round(4).to_dict(), "D_pc1_var_share": round(D_var, 4),
170+ "K_pc1_loadings": K_load.round(4).to_dict(), "K_pc1_var_share": round(K_var, 4),
171+ "complexity_pc1_var_share": round(C_var, 4), "complexity_pc1_n_vars": int(scmat.shape[1]),
172+ "irt_loglik": irt["loglik"], "irt_iterations": irt["iterations"],
173+ "n_D_pc1": int(out.D_pc1.notna().sum()), "n_K_mean": int(out.K_mean.notna().sum()), "n_K_irt": int(out.K_irt.notna().sum()),
174+ "n_P_main": int(out.P_main.notna().sum()), "n_P_main_crisis": int(cr.P_main.notna().sum()),
175+ "corr_zD_zK": {"D_pc1~K_mean": round(out.D_pc1.corr(out.K_mean), 4), "D_pc1~K_irt": round(out.D_pc1.corr(out.K_irt), 4),
176+ "log_pop~K_mean": round(out.log_pop.corr(out.K_mean), 4), "K_mean~K_irt": round(out.K_mean.corr(out.K_irt), 4),
177+ "K_mean~K_pc1": round(out.K_mean.corr(out.K_pc1), 4), "P_main~complexity_pc1": round(out.P_main.corr(out.complexity_pc1), 4),
178+ "P_main~D_pc1": round(out.P_main.corr(out.D_pc1), 4), "P_main~K_mean": round(out.P_main.corr(out.K_mean), 4)},
179+ "P_variance_share_of_D_plus_K": round(float(out.P_main.var() / (z(out.D_pc1).var() + z(out.K_mean).var())), 4),
180+ "block_correlations": blocks.corr().round(3).to_dict(),
181+ "block_alpha_by_macro_region": {}, "block_corr_mean_by_era": {},
182+ "measurement_error": {"D_pc1_halfwidth_median": float(((out.D_pc1_hi - out.D_pc1_lo) / 2).median()),
183+ "D_pc1_halfwidth_q90": float(((out.D_pc1_hi - out.D_pc1_lo) / 2).quantile(0.9)),
184+ "K_irt_psd_median": float(out.K_irt_psd.median()), "K_irt_psd_q90": float(out.K_irt_psd.quantile(0.9)),
185+ "sd_K_irt": float(out.K_irt.std()), "P_main_range_median": float((out.P_main_hi - out.P_main_lo).abs().median())}}
186+ for r, g in df.groupby("macro_region"):
187+ b = g[[f"K_{k}" for k in K_BLOCKS]].dropna()
188+ if len(b) >= 8:
189+ k = b.shape[1]; alpha = k / (k - 1) * (1 - b.var(ddof=1).sum() / b.sum(axis=1).var(ddof=1))
190+ diag["block_alpha_by_macro_region"][r] = {"n": int(len(b)), "alpha": round(float(alpha), 3)}
191+ for e, g in df.groupby("era"):
192+ b = g[[f"K_{k}" for k in K_BLOCKS]]
193+ c = b.corr().values; diag["block_corr_mean_by_era"][e] = {"n": int(b.dropna().shape[0]), "mean_offdiag_corr": round(float(c[np.triu_indices(4, 1)].mean()), 3)}
194+ # IRT invariance: refit on Old World vs Americas/Oceania and compare difficulties
195+ new_world = df.macro_region.isin(["North_America", "South_America", "Southeast_Asia_Oceania"])
196+ inv = {}
197+ for name, mask in [("old_world", ~new_world), ("new_world_oceania", new_world)]:
198+ f = fit_2pl(Xb[mask.values].values); inv[name] = {"n": int(mask.sum()), "b": f["b"].round(3).tolist(), "a": f["a"].round(3).tolist()}
199+ bo, bn = np.array(inv["old_world"]["b"]), np.array(inv["new_world_oceania"]["b"])
200+ ok = np.isfinite(bo) & np.isfinite(bn) & (np.abs(bo) < 5.9) & (np.abs(bn) < 5.9)
201+ diag["irt_invariance"] = {"items_compared": int(ok.sum()), "corr_difficulty_old_vs_new": round(float(np.corrcoef(bo[ok], bn[ok])[0, 1]), 3),
202+ "mean_abs_diff_difficulty": round(float(np.abs(bo[ok] - bn[ok]).mean()), 3), "items": BIN_K, "fits": inv}
203+ # leave-one-block-out sensitivity of K and P
204+ loo = {}
205+ for b in K_BLOCKS:
206+ others = [f"K_{k}" for k in K_BLOCKS if k != b]; kk = out[others].mean(axis=1).where(out[others].notna().sum(axis=1) >= 2)
207+ loo[b] = {"corr_K_with_full": round(float(kk.corr(out.K_mean)), 4), "corr_P_with_main": round(float((z(out.D_pc1) - z(kk)).corr(out.P_main)), 4)}
208+ diag["leave_one_block_out"] = loo
209+ (RES / "measurement_diagnostics.json").write_text(json.dumps(diag, indent=2))
210+ # ---- figures ----
211+ fig, axes = plt.subplots(1, 3, figsize=(12, 3.8))
212+ axes[0].scatter(out.K_mean, out.D_pc1, s=8, alpha=0.5, c="#444"); axes[0].set_xlabel("K (mean of 4 blocks)"); axes[0].set_ylabel("D (PC1 of log scale variables)"); axes[0].set_title(f"r = {out.D_pc1.corr(out.K_mean):.2f}, n = {int((out.D_pc1.notna() & out.K_mean.notna()).sum())}", fontsize=9)
213+ axes[1].scatter(out.K_mean, out.K_irt, s=8, alpha=0.5, c="#444"); axes[1].set_xlabel("K mean"); axes[1].set_ylabel("K IRT (2PL EAP)"); axes[1].set_title(f"r = {out.K_mean.corr(out.K_irt):.2f}", fontsize=9)
214+ axes[2].errorbar(item_params.b, range(len(item_params)), xerr=None, fmt="o", ms=3, color="#444"); axes[2].set_yticks(range(len(item_params))); axes[2].set_yticklabels(item_params.item, fontsize=5); axes[2].set_xlabel("IRT difficulty b"); axes[2].set_title("Item difficulties", fontsize=9)
215+ fig.tight_layout(); fig.savefig(FIG / "fig_measurement_DK.pdf"); plt.close(fig)
216+ fig, ax = plt.subplots(figsize=(4.5, 4.2)); ax.scatter(bo[ok], bn[ok], s=12, c="#444"); lim = [min(bo[ok].min(), bn[ok].min()) - .3, max(bo[ok].max(), bn[ok].max()) + .3]; ax.plot(lim, lim, "k--", lw=.8)
217+ ax.set_xlabel("difficulty, Old World fit"); ax.set_ylabel("difficulty, Americas/Oceania fit"); ax.set_title(f"IRT item invariance (r = {diag['irt_invariance']['corr_difficulty_old_vs_new']:.2f})", fontsize=9)
218+ fig.tight_layout(); fig.savefig(FIG / "fig_irt_invariance.pdf"); plt.close(fig)
219+ return out, cr, diag
220+
221+
222+def build_modern():
223+ p = pd.read_parquet(PROC / "modern_panel.parquet")
224+ items = p[K_ITEMS_MODERN]
225+ Zi = (items - items.mean()) / items.std(ddof=0)
226+ p["K_modern"] = Zi.mean(axis=1).where(Zi.notna().sum(axis=1) >= 4)
227+ p["K_modern_pc1"], Km_load, Km_var = pc1_pairwise(items, min_coded=4)
228+ p["D_modern"] = p.log_pop
229+ p["P_modern"] = z(p.D_modern) - z(p.K_modern)
230+ p["P_modern_pc1"] = z(p.D_modern) - z(p.K_modern_pc1)
231+ p["P_modern_within_year"] = p.groupby("year").D_modern.transform(z) - p.groupby("year").K_modern.transform(z)
232+ p["P_modern_area"] = z(((z(p.log_pop) + z(p.log_area)) / 2)) - z(p.K_modern)
233+ p.to_parquet(PROC / "modern_indices.parquet", index=False)
234+ diag = {"K_modern_pc1_loadings": Km_load.round(4).to_dict(), "K_modern_pc1_var_share": round(Km_var, 4),
235+ "n_P_modern": int(p.P_modern.notna().sum()), "corr_D_K_modern": round(float(p.D_modern.corr(p.K_modern)), 4),
236+ "corr_K_modern_polyarchy": round(float(p.K_modern.corr(p.v2x_polyarchy)), 4),
237+ "item_corr": items.corr().round(3).to_dict(),
238+ "corr_K_by_region": {int(r): round(float(g.D_modern.corr(g.K_modern)), 3) for r, g in p.groupby("e_regionpol_6C") if g.P_modern.notna().sum() > 50}}
239+ (RES / "measurement_diagnostics_modern.json").write_text(json.dumps(diag, indent=2))
240+ return p, diag
241+
242+
243+def power_h2a(cr: pd.DataFrame, n_rep: int = 400):
244+ """A-priori MDE for H2-A: random outcomes assigned to the actual P distribution of crisis records (no real Y used)."""
245+ rng = np.random.default_rng(SEED)
246+ d = cr.dropna(subset=["P_main"]).copy()
247+ P = z(d.P_main).values; reg = pd.factorize(d.seshat_region)[0]; n = len(P)
248+ base = 0.77 # Y1b positive share read from coverage_summary (122/158); used only to set the intercept
249+ res = []
250+ for beta in [0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0]:
251+ rej = 0
252+ for _ in range(n_rep):
253+ a0 = np.log(base / (1 - base))
254+ y = rng.random(n) < 1 / (1 + np.exp(-(a0 + beta * P)))
255+ try:
256+ m = sm.Logit(y.astype(float), sm.add_constant(P)).fit(disp=0, cov_type="cluster", cov_kwds={"groups": reg})
257+ rej += m.pvalues[1] < 0.05
258+ except Exception:
259+ pass
260+ res.append({"beta": beta, "power": rej / n_rep, "n": n})
261+ res = pd.DataFrame(res); res.to_csv(RES / "power_h2a_binary.csv", index=False)
262+ # fractional outcome: Binomial(14, logistic(a0 + beta P)) with base severity 0.29
263+ res2 = []
264+ for beta in [0.1, 0.15, 0.2, 0.3, 0.4, 0.5]:
265+ rej = 0
266+ for _ in range(n_rep):
267+ a0 = np.log(0.29 / 0.71); pr = 1 / (1 + np.exp(-(a0 + beta * P))); y = rng.binomial(14, pr) / 14
268+ try:
269+ m = sm.GLM(y, sm.add_constant(P), family=sm.families.Binomial()).fit(cov_type="cluster", cov_kwds={"groups": reg})
270+ rej += m.pvalues[1] < 0.05
271+ except Exception:
272+ pass
273+ res2.append({"beta": beta, "power": rej / n_rep, "n": n})
274+ res2 = pd.DataFrame(res2); res2.to_csv(RES / "power_h2a_fractional.csv", index=False)
275+ return res, res2
276+
277+
278+def main():
279+ out, cr, diag = build_seshat()
280+ p, dm = build_modern()
281+ pw1, pw2 = power_h2a(cr)
282+ print(json.dumps({k: v for k, v in diag.items() if k in ("D_pc1_loadings", "D_pc1_var_share", "K_pc1_loadings", "n_D_pc1", "n_K_mean", "n_K_irt", "n_P_main", "n_P_main_crisis", "corr_zD_zK", "P_variance_share_of_D_plus_K", "measurement_error", "irt_invariance", "leave_one_block_out", "block_alpha_by_macro_region")}, indent=1, default=str)[:6000])
283+ print(json.dumps({k: v for k, v in dm.items() if k != "item_corr"}, indent=1))
284+ print("power binary:\n", pw1.to_string(index=False)); print("power fractional:\n", pw2.to_string(index=False))
285+
286+
287+if __name__ == "__main__":
288+ main()
added src/wp35/measurement/irt.py +63 −0
@@ -0,0 +1,63 @@
1+"""Two-parameter logistic IRT (marginal maximum likelihood, EM with Gauss–Hermite quadrature).
2+
3+Items are binary (0/1); missing entries contribute nothing to the likelihood (missing-at-random by design, no
4+imputation). Returns item discriminations a_j, difficulties b_j, EAP scores and posterior SDs for each unit.
5+Deterministic (no randomness). Written for transparency rather than speed (N ≈ 600 × J ≈ 30 is instant).
6+"""
7+from __future__ import annotations
8+import numpy as np
9+from scipy.special import expit
10+
11+
12+def fit_2pl(X: np.ndarray, n_quad: int = 41, max_iter: int = 1000, tol: float = 1e-6, min_items: int = 3,
13+ prior_sd_log_a: float = 0.5, prior_sd_b: float = 2.0):
14+ """MAP variant: log-normal prior on a (mean 0, sd prior_sd_log_a) and normal prior on b (sd prior_sd_b) to keep
15+ items that are nearly constant in the sample from running to the boundary (Heywood-type cases)."""
16+ X = np.asarray(X, dtype=float)
17+ N, J = X.shape
18+ obs = ~np.isnan(X)
19+ Xz = np.where(obs, X, 0.0)
20+ nodes, weights = np.polynomial.hermite_e.hermegauss(n_quad) # standard normal prior
21+ weights = weights / weights.sum()
22+ a = np.ones(J); b = np.zeros(J)
23+ ll_old = -np.inf
24+ for it in range(max_iter):
25+ # E-step: posterior over nodes for each unit
26+ P = expit(a[None, :] * (nodes[:, None] - b[None, :])) # Q x J
27+ logP, log1P = np.log(P + 1e-300), np.log(1 - P + 1e-300)
28+ # log-lik of unit i at node q = sum_j obs_ij [x_ij logP_qj + (1-x_ij) log(1-P_qj)]
29+ L = Xz @ logP.T + ((1 - Xz) * obs) @ log1P.T # N x Q
30+ L += np.log(weights)[None, :]
31+ m = L.max(axis=1, keepdims=True)
32+ post = np.exp(L - m); norm = post.sum(axis=1, keepdims=True); post /= norm
33+ ll = float((m.squeeze() + np.log(norm.squeeze())).sum())
34+ # M-step: Newton updates per item on expected counts
35+ r = post.T @ Xz # Q x J expected correct
36+ n = post.T @ obs.astype(float) # Q x J expected tries
37+ for j in range(J):
38+ for _ in range(10):
39+ p = expit(a[j] * (nodes - b[j]))
40+ g_a = np.sum((r[:, j] - n[:, j] * p) * (nodes - b[j])) - (np.log(a[j]) / prior_sd_log_a ** 2) / a[j]
41+ g_b = np.sum((r[:, j] - n[:, j] * p) * (-a[j])) - b[j] / prior_sd_b ** 2
42+ w = n[:, j] * p * (1 - p)
43+ H_aa = -np.sum(w * (nodes - b[j]) ** 2) - (1 - np.log(a[j])) / (prior_sd_log_a ** 2 * a[j] ** 2)
44+ H_bb = -np.sum(w * a[j] ** 2) - 1 / prior_sd_b ** 2
45+ H_ab = np.sum(w * a[j] * (nodes - b[j]))
46+ H = np.array([[H_aa, H_ab], [H_ab, H_bb]]) - 1e-6 * np.eye(2)
47+ step = np.linalg.solve(H, np.array([g_a, g_b]))
48+ a[j] -= step[0]; b[j] -= step[1]
49+ a[j] = float(np.clip(a[j], 0.05, 8.0)); b[j] = float(np.clip(b[j], -6, 6))
50+ if np.abs(step).max() < 1e-6:
51+ break
52+ if abs(ll - ll_old) < tol:
53+ break
54+ ll_old = ll
55+ # final EAP
56+ P = expit(a[None, :] * (nodes[:, None] - b[None, :]))
57+ L = Xz @ np.log(P + 1e-300).T + ((1 - Xz) * obs) @ np.log(1 - P + 1e-300).T + np.log(weights)[None, :]
58+ m = L.max(axis=1, keepdims=True); post = np.exp(L - m); post /= post.sum(axis=1, keepdims=True)
59+ eap = post @ nodes
60+ psd = np.sqrt(np.maximum(post @ nodes ** 2 - eap ** 2, 0))
61+ n_items = obs.sum(axis=1)
62+ eap = np.where(n_items >= min_items, eap, np.nan); psd = np.where(n_items >= min_items, psd, np.nan)
63+ return {"a": a, "b": b, "eap": eap, "psd": psd, "loglik": ll, "iterations": it + 1, "n_items": n_items}
64
Menu

Theme