AlexDunstan commited on
Commit
1dbf2d8
·
verified ·
1 Parent(s): 75e2264

Deploy plain static Space bundle

Browse files

Replace broken Gradio Lite build with plain static JS bundle and remove stale public files.

Files changed (6) hide show
  1. README.md +1 -1
  2. app.mjs +226 -0
  3. classifier.mjs +399 -0
  4. index.html +126 -1469
  5. style.css +0 -28
  6. styles.css +340 -0
README.md CHANGED
@@ -12,7 +12,7 @@ pinned: false
12
 
13
  This Space is a compact public-facing demo for exploring 2x2 payoff matrices.
14
 
15
- It is packaged as a static Hugging Face Space using Gradio Lite, so the app runs client-side in the browser.
16
 
17
  It does three things:
18
 
 
12
 
13
  This Space is a compact public-facing demo for exploring 2x2 payoff matrices.
14
 
15
+ It is packaged as a plain static Hugging Face Space, so the app runs client-side in the browser with no Python runtime and no Gradio dependency.
16
 
17
  It does three things:
18
 
app.mjs ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ PRESETS,
3
+ GAME_TYPE_COLORS,
4
+ GAME_TYPE_DESCRIPTIONS,
5
+ buildMatrix,
6
+ classifyFull,
7
+ } from "./classifier.mjs";
8
+
9
+ const INPUT_IDS = [
10
+ "r0c0_p1",
11
+ "r0c0_p2",
12
+ "r0c1_p1",
13
+ "r0c1_p2",
14
+ "r1c0_p1",
15
+ "r1c0_p2",
16
+ "r1c1_p1",
17
+ "r1c1_p2",
18
+ ];
19
+
20
+ const DEFAULT_PRESET = "Prisoner's Dilemma";
21
+
22
+ function init() {
23
+ const presetSelect = document.querySelector("#preset");
24
+ const analyzeButton = document.querySelector("#analyze");
25
+ const resetButton = document.querySelector("#reset");
26
+
27
+ for (const name of Object.keys(PRESETS)) {
28
+ const option = document.createElement("option");
29
+ option.value = name;
30
+ option.textContent = name;
31
+ presetSelect.append(option);
32
+ }
33
+
34
+ presetSelect.value = DEFAULT_PRESET;
35
+ applyPreset(DEFAULT_PRESET);
36
+
37
+ presetSelect.addEventListener("change", () => {
38
+ applyPreset(presetSelect.value);
39
+ });
40
+
41
+ analyzeButton.addEventListener("click", render);
42
+ resetButton.addEventListener("click", () => {
43
+ presetSelect.value = DEFAULT_PRESET;
44
+ applyPreset(DEFAULT_PRESET);
45
+ });
46
+
47
+ for (const inputId of INPUT_IDS) {
48
+ document.querySelector(`#${inputId}`).addEventListener("input", render);
49
+ }
50
+ }
51
+
52
+ function applyPreset(name) {
53
+ const values = PRESETS[name];
54
+ if (!values) {
55
+ return;
56
+ }
57
+
58
+ INPUT_IDS.forEach((inputId, index) => {
59
+ document.querySelector(`#${inputId}`).value = String(values[index]);
60
+ });
61
+
62
+ render();
63
+ }
64
+
65
+ function currentPayoffs() {
66
+ return INPUT_IDS.map((inputId) => {
67
+ const raw = document.querySelector(`#${inputId}`).value;
68
+ const parsed = Number.parseInt(raw, 10);
69
+ return Number.isNaN(parsed) ? 0 : parsed;
70
+ });
71
+ }
72
+
73
+ function render() {
74
+ const payoffs = currentPayoffs();
75
+ const matrix = buildMatrix(payoffs);
76
+ const result = classifyFull(matrix);
77
+
78
+ renderMatrix(matrix, result.ne);
79
+ renderSummary(matrix, result);
80
+ renderClassification(result);
81
+ renderProperties(result.props, result.ne);
82
+ }
83
+
84
+ function renderMatrix(matrix, equilibria) {
85
+ const neKeys = new Set(equilibria.map(([row, col]) => `${row}-${col}`));
86
+ const tbody = document.querySelector("#matrix-body");
87
+ tbody.innerHTML = "";
88
+
89
+ for (let row = 0; row < 2; row += 1) {
90
+ const tr = document.createElement("tr");
91
+ for (let col = 0; col < 2; col += 1) {
92
+ const td = document.createElement("td");
93
+ const [p1, p2] = matrix[row][col];
94
+ const isNe = neKeys.has(`${row}-${col}`);
95
+ td.className = isNe ? "matrix-cell is-ne" : "matrix-cell";
96
+ td.innerHTML = `
97
+ <div class="cell-coord">Row ${row} / Col ${col}</div>
98
+ <div class="cell-payoff">(${p1}, ${p2})</div>
99
+ <div class="cell-tag">${isNe ? "NASH EQUILIBRIUM" : " "}</div>
100
+ `;
101
+ tr.append(td);
102
+ }
103
+ tbody.append(tr);
104
+ }
105
+ }
106
+
107
+ function renderSummary(matrix, result) {
108
+ const summary = document.querySelector("#summary");
109
+ const positions = result.ne.map(([row, col]) => `(${row}, ${col})`).join(", ");
110
+
111
+ if (result.ne.length === 0) {
112
+ if (result.props.mixed_exists) {
113
+ summary.innerHTML = `
114
+ <div class="summary-line">Pure NE: none</div>
115
+ <div class="summary-line">Mixed NE: P1 Row 0 = <strong>${formatNumber(result.props.mixed_p)}</strong>, P2 Col 0 = <strong>${formatNumber(result.props.mixed_q)}</strong></div>
116
+ <div class="summary-line">Expected payoffs: <strong>(${formatNumber(result.props.mixed_payoff_p1)}, ${formatNumber(result.props.mixed_payoff_p2)})</strong></div>
117
+ `;
118
+ return;
119
+ }
120
+
121
+ summary.innerHTML = `
122
+ <div class="summary-line">Pure NE: none</div>
123
+ <div class="summary-line">Mixed NE: degenerate</div>
124
+ `;
125
+ return;
126
+ }
127
+
128
+ if (result.ne.length === 1) {
129
+ const [row, col] = result.ne[0];
130
+ summary.innerHTML = `
131
+ <div class="summary-line">Pure NE: <strong>1</strong></div>
132
+ <div class="summary-line">Position: <strong>(${row}, ${col})</strong></div>
133
+ <div class="summary-line">Payoffs: <strong>(${matrix[row][col][0]}, ${matrix[row][col][1]})</strong></div>
134
+ `;
135
+ return;
136
+ }
137
+
138
+ summary.innerHTML = `
139
+ <div class="summary-line">Pure NE: <strong>${result.ne.length}</strong></div>
140
+ <div class="summary-line">Positions: <strong>${positions}</strong></div>
141
+ <div class="summary-line">Best NE welfare: <strong>${Math.max(...result.props.ne_welfare)}</strong></div>
142
+ `;
143
+ }
144
+
145
+ function renderClassification(result) {
146
+ const label = result.label;
147
+ const badge = document.querySelector("#game-type-badge");
148
+ const desc = document.querySelector("#game-type-description");
149
+ const color = GAME_TYPE_COLORS[label] || "#7A7570";
150
+
151
+ badge.textContent = label;
152
+ badge.style.color = color;
153
+ badge.style.borderColor = color;
154
+ desc.textContent = GAME_TYPE_DESCRIPTIONS[label] || "";
155
+ }
156
+
157
+ function renderProperties(props, equilibria) {
158
+ const rows = [
159
+ propertyRow("P1 dominant strategy", boolBadge(props.p1_has_dominant), "weakly best in every column"),
160
+ propertyRow("P2 dominant strategy", boolBadge(props.p2_has_dominant), "weakly best in every row"),
161
+ propertyRow("Both dominant", boolBadge(props.both_dominant), ""),
162
+ propertyRow("Zero-sum", boolBadge(props.is_zero_sum), "payoff sum constant across cells"),
163
+ propertyRow("Symmetric", boolBadge(props.is_symmetric), "payoff swap across diagonal"),
164
+ propertyRow("Pure NE count", String(props.ne_count), equilibria.length ? formatPositions(equilibria) : "none"),
165
+ propertyRow("Any NE Pareto-dominated", boolBadge(props.has_pareto_dom_ne), ""),
166
+ propertyRow("All NE Pareto-efficient", boolBadge(props.all_ne_pareto_eff), ""),
167
+ propertyRow("Max social welfare", String(props.max_welfare), "best p1 + p2"),
168
+ propertyRow("NE welfare", props.ne_welfare.length ? props.ne_welfare.join(", ") : "-", ""),
169
+ propertyRow("Welfare loss", String(props.welfare_loss), "max welfare minus best NE welfare"),
170
+ ];
171
+
172
+ if (props.ne_count > 0) {
173
+ rows.push(
174
+ propertyRow("NE payoffs P1", props.ne_p1_payoffs.join(", "), "per equilibrium"),
175
+ propertyRow("NE payoffs P2", props.ne_p2_payoffs.join(", "), "per equilibrium"),
176
+ propertyRow("Payoff diff (P1-P2)", props.ne_payoff_diffs.join(", "), "per equilibrium"),
177
+ propertyRow("Any NE equal payoffs", boolBadge(props.ne_has_equal_payoffs), ""),
178
+ propertyRow("Mean abs diff at NE", props.ne_mean_abs_diff === null ? "-" : formatNumber(props.ne_mean_abs_diff), "")
179
+ );
180
+ }
181
+
182
+ if (props.ne_count === 0) {
183
+ if (props.mixed_exists) {
184
+ rows.push(
185
+ propertyRow("Mixed P1 plays Row 0", `p = ${formatNumber(props.mixed_p)}`, ""),
186
+ propertyRow("Mixed P2 plays Col 0", `q = ${formatNumber(props.mixed_q)}`, ""),
187
+ propertyRow(
188
+ "Mixed expected payoffs",
189
+ `(${formatNumber(props.mixed_payoff_p1)}, ${formatNumber(props.mixed_payoff_p2)})`,
190
+ ""
191
+ )
192
+ );
193
+ } else {
194
+ rows.push(propertyRow("Mixed strategy", "degenerate", "denominator zero"));
195
+ }
196
+ }
197
+
198
+ document.querySelector("#properties-body").innerHTML = rows.join("");
199
+ }
200
+
201
+ function propertyRow(label, value, note) {
202
+ return `
203
+ <tr>
204
+ <td>${label}</td>
205
+ <td>${value}</td>
206
+ <td>${note}</td>
207
+ </tr>
208
+ `;
209
+ }
210
+
211
+ function boolBadge(value) {
212
+ return `<span class="${value ? "bool-yes" : "bool-no"}">${value ? "YES" : "NO"}</span>`;
213
+ }
214
+
215
+ function formatPositions(positions) {
216
+ return positions.map(([row, col]) => `(${row}, ${col})`).join(", ");
217
+ }
218
+
219
+ function formatNumber(value) {
220
+ if (Number.isInteger(value)) {
221
+ return String(value);
222
+ }
223
+ return Number(value).toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
224
+ }
225
+
226
+ init();
classifier.mjs ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const PRESETS = {
2
+ "Prisoner's Dilemma": [3, 3, 0, 5, 5, 0, 1, 1],
3
+ "Stag Hunt": [4, 4, 1, 3, 3, 1, 2, 2],
4
+ "Chicken": [3, 3, 1, 4, 4, 1, 0, 0],
5
+ "Coordination Game": [2, 2, 0, 0, 0, 0, 2, 2],
6
+ "Battle of the Sexes": [3, 2, 0, 0, 0, 0, 2, 3],
7
+ "Matching Pennies": [1, -1, -1, 1, -1, 1, 1, -1],
8
+ "All Equal (4 NE)": [3, 3, 3, 3, 3, 3, 3, 3],
9
+ };
10
+
11
+ export const GAME_TYPE_COLORS = {
12
+ "Prisoner's Dilemma": "#E8610A",
13
+ Harmony: "#4CAF82",
14
+ Deadlock: "#C0392B",
15
+ "Battle of the Sexes": "#D97706",
16
+ "Stag Hunt": "#1D8A6B",
17
+ Chicken: "#C84B31",
18
+ Coordination: "#3A9BD5",
19
+ "Zero-Sum": "#9B59B6",
20
+ "Dominant (P1 only)": "#F39C12",
21
+ "Dominant (P2 only)": "#E67E22",
22
+ "No Equilibrium": "#7A7570",
23
+ Other: "#4A4540",
24
+ };
25
+
26
+ export const GAME_TYPE_DESCRIPTIONS = {
27
+ "Zero-Sum":
28
+ "A zero-sum game: one player's gain is exactly the other's loss. The total welfare is constant across all outcomes. Classic examples: chess, poker, matching pennies.",
29
+ "Prisoner's Dilemma":
30
+ "A social dilemma: both players have a dominant strategy, but the Nash equilibrium leaves both worse off than if they had cooperated. Rational individual behaviour produces a collectively suboptimal result.",
31
+ Harmony:
32
+ "A harmony game: both players have dominant strategies and the Nash equilibrium is Pareto-efficient. Rational self-interest aligns with the socially optimal outcome.",
33
+ Deadlock:
34
+ "Both players have dominant strategies leading to an equilibrium, but unlike the Prisoner's Dilemma the cooperative outcome is not better for both. Mutual defection is both rational and efficient.",
35
+ "Battle of the Sexes":
36
+ "An asymmetric coordination game with two diagonal equilibria. Both players want to coordinate, but each prefers a different equilibrium.",
37
+ "Stag Hunt":
38
+ "A symmetric coordination game with one high-reward cooperative equilibrium and one safer fallback equilibrium. Trust matters because failing to coordinate can be costly.",
39
+ Chicken:
40
+ "A symmetric anti-coordination game with off-diagonal equilibria. Each player wants the other side to yield, creating brinkmanship instead of stable mutual cooperation.",
41
+ Coordination:
42
+ "A coordination-style game: multiple Nash equilibria exist and the main strategic problem is choosing which stable outcome to coordinate on.",
43
+ "Dominant (P1 only)":
44
+ "Only Player 1 has a dominant strategy. Player 2's best response depends on what Player 1 does, but Player 1 always plays the same way.",
45
+ "Dominant (P2 only)":
46
+ "Only Player 2 has a dominant strategy. Player 1's best response depends on what Player 2 does, but Player 2 always plays the same way.",
47
+ "No Equilibrium":
48
+ "No pure-strategy Nash equilibrium exists. Best responses cycle, so the stable object is a mixed-strategy equilibrium.",
49
+ Other:
50
+ "A game that does not fit neatly into the classic taxonomy. Neither player has a dominant strategy and there is at least one pure-strategy Nash equilibrium.",
51
+ };
52
+
53
+ export function buildMatrix(payoffs) {
54
+ return [
55
+ [[payoffs[0], payoffs[1]], [payoffs[2], payoffs[3]]],
56
+ [[payoffs[4], payoffs[5]], [payoffs[6], payoffs[7]]],
57
+ ];
58
+ }
59
+
60
+ export function findNashEquilibria(matrix) {
61
+ const equilibria = [];
62
+ for (let row = 0; row < matrix.length; row += 1) {
63
+ for (let col = 0; col < matrix[row].length; col += 1) {
64
+ const p1 = matrix[row][col][0];
65
+ const p2 = matrix[row][col][1];
66
+
67
+ let rowBest = true;
68
+ for (let otherRow = 0; otherRow < matrix.length; otherRow += 1) {
69
+ if (matrix[otherRow][col][0] > p1) {
70
+ rowBest = false;
71
+ break;
72
+ }
73
+ }
74
+
75
+ let colBest = true;
76
+ for (let otherCol = 0; otherCol < matrix[row].length; otherCol += 1) {
77
+ if (matrix[row][otherCol][1] > p2) {
78
+ colBest = false;
79
+ break;
80
+ }
81
+ }
82
+
83
+ if (rowBest && colBest) {
84
+ equilibria.push([row, col]);
85
+ }
86
+ }
87
+ }
88
+ return equilibria;
89
+ }
90
+
91
+ function hasDominantStrategy(matrix, player) {
92
+ const rows = matrix.length;
93
+ const cols = matrix[0].length;
94
+
95
+ if (player === 0) {
96
+ for (let candidateRow = 0; candidateRow < rows; candidateRow += 1) {
97
+ let dominant = true;
98
+ for (let row = 0; row < rows; row += 1) {
99
+ for (let col = 0; col < cols; col += 1) {
100
+ if (matrix[candidateRow][col][0] < matrix[row][col][0]) {
101
+ dominant = false;
102
+ break;
103
+ }
104
+ }
105
+ if (!dominant) {
106
+ break;
107
+ }
108
+ }
109
+ if (dominant) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ for (let candidateCol = 0; candidateCol < cols; candidateCol += 1) {
117
+ let dominant = true;
118
+ for (let row = 0; row < rows; row += 1) {
119
+ for (let col = 0; col < cols; col += 1) {
120
+ if (matrix[row][candidateCol][1] < matrix[row][col][1]) {
121
+ dominant = false;
122
+ break;
123
+ }
124
+ }
125
+ if (!dominant) {
126
+ break;
127
+ }
128
+ }
129
+ if (dominant) {
130
+ return true;
131
+ }
132
+ }
133
+
134
+ return false;
135
+ }
136
+
137
+ function paretoDominated(targetRow, targetCol, matrix) {
138
+ const hereP1 = matrix[targetRow][targetCol][0];
139
+ const hereP2 = matrix[targetRow][targetCol][1];
140
+
141
+ for (let row = 0; row < matrix.length; row += 1) {
142
+ for (let col = 0; col < matrix[row].length; col += 1) {
143
+ if (row === targetRow && col === targetCol) {
144
+ continue;
145
+ }
146
+ const thereP1 = matrix[row][col][0];
147
+ const thereP2 = matrix[row][col][1];
148
+ if (thereP1 >= hereP1 && thereP2 >= hereP2 && (thereP1 > hereP1 || thereP2 > hereP2)) {
149
+ return true;
150
+ }
151
+ }
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ export function computeMixedStrategy2x2(matrix) {
158
+ if (matrix.length !== 2 || matrix[0].length !== 2) {
159
+ return {
160
+ mixed_exists: false,
161
+ mixed_p: null,
162
+ mixed_q: null,
163
+ mixed_payoff_p1: null,
164
+ mixed_payoff_p2: null,
165
+ };
166
+ }
167
+
168
+ const a = Number(matrix[0][0][0]);
169
+ const e = Number(matrix[0][0][1]);
170
+ const b = Number(matrix[0][1][0]);
171
+ const f = Number(matrix[0][1][1]);
172
+ const c = Number(matrix[1][0][0]);
173
+ const g = Number(matrix[1][0][1]);
174
+ const d = Number(matrix[1][1][0]);
175
+ const h = Number(matrix[1][1][1]);
176
+
177
+ const denomP = e - g - f + h;
178
+ const denomQ = a - b - c + d;
179
+
180
+ if (denomP === 0 || denomQ === 0) {
181
+ return {
182
+ mixed_exists: false,
183
+ mixed_p: null,
184
+ mixed_q: null,
185
+ mixed_payoff_p1: null,
186
+ mixed_payoff_p2: null,
187
+ };
188
+ }
189
+
190
+ const eps = 1e-9;
191
+ let p = (h - g) / denomP;
192
+ let q = (d - b) / denomQ;
193
+
194
+ if (!(p >= -eps && p <= 1 + eps && q >= -eps && q <= 1 + eps)) {
195
+ return {
196
+ mixed_exists: false,
197
+ mixed_p: null,
198
+ mixed_q: null,
199
+ mixed_payoff_p1: null,
200
+ mixed_payoff_p2: null,
201
+ };
202
+ }
203
+
204
+ p = Math.max(0, Math.min(1, p));
205
+ q = Math.max(0, Math.min(1, q));
206
+
207
+ return {
208
+ mixed_exists: true,
209
+ mixed_p: round(p, 6),
210
+ mixed_q: round(q, 6),
211
+ mixed_payoff_p1: round(q * a + (1 - q) * b, 6),
212
+ mixed_payoff_p2: round(p * e + (1 - p) * g, 6),
213
+ };
214
+ }
215
+
216
+ function nePayoffStats(matrix, nePositions) {
217
+ if (nePositions.length === 0) {
218
+ return {
219
+ ne_p1_payoffs: [],
220
+ ne_p2_payoffs: [],
221
+ ne_payoff_diffs: [],
222
+ ne_has_equal_payoffs: false,
223
+ ne_mean_abs_diff: null,
224
+ };
225
+ }
226
+
227
+ const p1Payoffs = nePositions.map(([row, col]) => matrix[row][col][0]);
228
+ const p2Payoffs = nePositions.map(([row, col]) => matrix[row][col][1]);
229
+ const diffs = p1Payoffs.map((value, index) => value - p2Payoffs[index]);
230
+ const absMean = diffs.reduce((sum, diff) => sum + Math.abs(diff), 0) / diffs.length;
231
+
232
+ return {
233
+ ne_p1_payoffs: p1Payoffs,
234
+ ne_p2_payoffs: p2Payoffs,
235
+ ne_payoff_diffs: diffs,
236
+ ne_has_equal_payoffs: diffs.some((diff) => diff === 0),
237
+ ne_mean_abs_diff: absMean,
238
+ };
239
+ }
240
+
241
+ export function classifyProperties(matrix, nePositions) {
242
+ const p1Dominant = hasDominantStrategy(matrix, 0);
243
+ const p2Dominant = hasDominantStrategy(matrix, 1);
244
+
245
+ const sums = [];
246
+ let maxWelfare = -Infinity;
247
+ for (let row = 0; row < matrix.length; row += 1) {
248
+ for (let col = 0; col < matrix[row].length; col += 1) {
249
+ const welfare = matrix[row][col][0] + matrix[row][col][1];
250
+ sums.push(welfare);
251
+ if (welfare > maxWelfare) {
252
+ maxWelfare = welfare;
253
+ }
254
+ }
255
+ }
256
+ const isZeroSum = sums.every((value) => value === sums[0]);
257
+
258
+ let isSymmetric = matrix.length === matrix[0].length;
259
+ if (isSymmetric) {
260
+ for (let row = 0; row < matrix.length; row += 1) {
261
+ for (let col = 0; col < matrix[row].length; col += 1) {
262
+ if (matrix[row][col][0] !== matrix[col][row][1]) {
263
+ isSymmetric = false;
264
+ break;
265
+ }
266
+ }
267
+ if (!isSymmetric) {
268
+ break;
269
+ }
270
+ }
271
+ }
272
+
273
+ const neWelfare = nePositions.map(([row, col]) => matrix[row][col][0] + matrix[row][col][1]);
274
+ const maxNeWelfare = neWelfare.length > 0 ? Math.max(...neWelfare) : 0;
275
+ const welfareLoss = maxWelfare - maxNeWelfare;
276
+
277
+ const paretoFlags = nePositions.map(([row, col]) => paretoDominated(row, col, matrix));
278
+ const hasParetoDominatedNe = paretoFlags.some(Boolean);
279
+
280
+ const mixed = nePositions.length === 0 ? computeMixedStrategy2x2(matrix) : {
281
+ mixed_exists: false,
282
+ mixed_p: null,
283
+ mixed_q: null,
284
+ mixed_payoff_p1: null,
285
+ mixed_payoff_p2: null,
286
+ };
287
+
288
+ const asym = nePayoffStats(matrix, nePositions);
289
+
290
+ return {
291
+ p1_has_dominant: p1Dominant,
292
+ p2_has_dominant: p2Dominant,
293
+ both_dominant: p1Dominant && p2Dominant,
294
+ is_zero_sum: isZeroSum,
295
+ is_symmetric: isSymmetric,
296
+ ne_count: nePositions.length,
297
+ has_pareto_dom_ne: hasParetoDominatedNe,
298
+ all_ne_pareto_eff: !hasParetoDominatedNe,
299
+ max_welfare: maxWelfare,
300
+ ne_welfare: neWelfare,
301
+ welfare_loss: welfareLoss,
302
+ mixed_exists: mixed.mixed_exists,
303
+ mixed_p: mixed.mixed_p,
304
+ mixed_q: mixed.mixed_q,
305
+ mixed_payoff_p1: mixed.mixed_payoff_p1,
306
+ mixed_payoff_p2: mixed.mixed_payoff_p2,
307
+ ne_p1_payoffs: asym.ne_p1_payoffs,
308
+ ne_p2_payoffs: asym.ne_p2_payoffs,
309
+ ne_payoff_diffs: asym.ne_payoff_diffs,
310
+ ne_has_equal_payoffs: asym.ne_has_equal_payoffs,
311
+ ne_mean_abs_diff: asym.ne_mean_abs_diff,
312
+ };
313
+ }
314
+
315
+ function isDiagonalPair(nePositions) {
316
+ return nePositions.length === 2
317
+ && nePositions.some(([row, col]) => row === 0 && col === 0)
318
+ && nePositions.some(([row, col]) => row === 1 && col === 1);
319
+ }
320
+
321
+ function isOffDiagonalPair(nePositions) {
322
+ return nePositions.length === 2
323
+ && nePositions.some(([row, col]) => row === 0 && col === 1)
324
+ && nePositions.some(([row, col]) => row === 1 && col === 0);
325
+ }
326
+
327
+ export function classifyGameType(props, nePositions, matrix) {
328
+ if (props.is_zero_sum) {
329
+ return "Zero-Sum";
330
+ }
331
+
332
+ if (props.both_dominant) {
333
+ if (props.has_pareto_dom_ne && props.welfare_loss > 0) {
334
+ return "Prisoner's Dilemma";
335
+ }
336
+ if (props.welfare_loss === 0) {
337
+ return "Harmony";
338
+ }
339
+ return "Deadlock";
340
+ }
341
+
342
+ if (matrix && isDiagonalPair(nePositions)) {
343
+ const tl = matrix[0][0];
344
+ const br = matrix[1][1];
345
+
346
+ const p1PrefersTl = tl[0] > br[0];
347
+ const p1PrefersBr = br[0] > tl[0];
348
+ const p2PrefersTl = tl[1] > br[1];
349
+ const p2PrefersBr = br[1] > tl[1];
350
+
351
+ if ((p1PrefersTl && p2PrefersBr) || (p1PrefersBr && p2PrefersTl)) {
352
+ return "Battle of the Sexes";
353
+ }
354
+
355
+ if (props.is_symmetric) {
356
+ const tlWelfare = tl[0] + tl[1];
357
+ const brWelfare = br[0] + br[1];
358
+ if (tlWelfare !== brWelfare) {
359
+ return "Stag Hunt";
360
+ }
361
+ }
362
+
363
+ return "Coordination";
364
+ }
365
+
366
+ if (matrix && isOffDiagonalPair(nePositions) && props.is_symmetric) {
367
+ return "Chicken";
368
+ }
369
+
370
+ if (nePositions.length >= 2 && props.is_symmetric) {
371
+ return "Coordination";
372
+ }
373
+
374
+ if (props.p1_has_dominant && !props.p2_has_dominant) {
375
+ return "Dominant (P1 only)";
376
+ }
377
+
378
+ if (props.p2_has_dominant && !props.p1_has_dominant) {
379
+ return "Dominant (P2 only)";
380
+ }
381
+
382
+ if (props.ne_count === 0) {
383
+ return "No Equilibrium";
384
+ }
385
+
386
+ return "Other";
387
+ }
388
+
389
+ export function classifyFull(matrix) {
390
+ const ne = findNashEquilibria(matrix);
391
+ const props = classifyProperties(matrix, ne);
392
+ const label = classifyGameType(props, ne, matrix);
393
+ return { ne, props, label };
394
+ }
395
+
396
+ function round(value, digits) {
397
+ const factor = 10 ** digits;
398
+ return Math.round(value * factor) / factor;
399
+ }
index.html CHANGED
@@ -4,1476 +4,133 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Game Theory Matrix Classifier</title>
7
- <meta name="description" content="Client-side 2x2 game theory matrix explorer and classifier built with Gradio Lite.">
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gradio/lite/dist/lite.css">
9
- <style>
10
- html, body {
11
- margin: 0;
12
- min-height: 100%;
13
- background: #0F0F0F;
14
- }
15
- body {
16
- background:
17
- radial-gradient(circle at top right, rgba(232, 97, 10, 0.12), transparent 28rem),
18
- linear-gradient(180deg, #0F0F0F 0%, #120F0C 100%);
19
- }
20
- </style>
21
  </head>
22
  <body>
23
- <gradio-lite>
24
- <gradio-requirements>
25
- numpy==2.2.6
26
- </gradio-requirements>
27
- <gradio-file name="app.py" entrypoint="true">
28
- &quot;&quot;&quot;
29
- app.py Game Theory Matrix Classifier · Gradio Lite source app
30
- ──────────────────────────────────────────────────────────────────
31
- Used as the Python entrypoint embedded into the exported static HF Space.
32
- &quot;&quot;&quot;
33
-
34
- import sys
35
- from pathlib import Path
36
-
37
- import gradio as gr
38
- import numpy as np
39
-
40
- sys.path.insert(0, str(Path(__file__).parent))
41
-
42
- from src.matrix_permutations import find_nash_equilibria
43
- from src.analysis import classify_full, GAME_TYPE_DESCRIPTIONS
44
- from src.theme import (
45
- GRADIO_CSS,
46
- ACCENT, BG_PAGE, BG_SURFACE, BG_ALT,
47
- BORDER_STRONG, TEXT_PRI, TEXT_MUT, TEXT_DIM,
48
- )
49
-
50
-
51
- PRESETS = {
52
- &quot;Prisoner&#x27;s Dilemma&quot;: (3, 3, 0, 5, 5, 0, 1, 1),
53
- &quot;Stag Hunt&quot;: (4, 4, 1, 3, 3, 1, 2, 2),
54
- &quot;Chicken&quot;: (3, 3, 1, 4, 4, 1, 0, 0),
55
- &quot;Coordination Game&quot;: (2, 2, 0, 0, 0, 0, 2, 2),
56
- &quot;Battle of the Sexes&quot;:(3, 2, 0, 0, 0, 0, 2, 3),
57
- &quot;Matching Pennies&quot;: (1, -1, -1, 1, -1, 1, 1, -1),
58
- &quot;All Equal (4 NE)&quot;: (3, 3, 3, 3, 3, 3, 3, 3),
59
- }
60
-
61
- GAME_TYPE_COLORS = {
62
- &quot;Prisoner&#x27;s Dilemma&quot;: &quot;#E8610A&quot;,
63
- &quot;Harmony&quot;: &quot;#4CAF82&quot;,
64
- &quot;Deadlock&quot;: &quot;#C0392B&quot;,
65
- &quot;Battle of the Sexes&quot;:&quot;#D97706&quot;,
66
- &quot;Stag Hunt&quot;: &quot;#1D8A6B&quot;,
67
- &quot;Chicken&quot;: &quot;#C84B31&quot;,
68
- &quot;Coordination&quot;: &quot;#3A9BD5&quot;,
69
- &quot;Zero-Sum&quot;: &quot;#9B59B6&quot;,
70
- &quot;Dominant (P1 only)&quot;: &quot;#F39C12&quot;,
71
- &quot;Dominant (P2 only)&quot;: &quot;#E67E22&quot;,
72
- &quot;No Equilibrium&quot;: &quot;#7A7570&quot;,
73
- &quot;Other&quot;: &quot;#4A4540&quot;,
74
- }
75
-
76
-
77
- def load_preset(name):
78
- return PRESETS.get(name, (0,) * 8)
79
-
80
-
81
- def _parse(vals):
82
- return [int(v) for v in vals]
83
-
84
-
85
- def _build_matrix(payoffs):
86
- return np.array([
87
- [[payoffs[0], payoffs[1]], [payoffs[2], payoffs[3]]],
88
- [[payoffs[4], payoffs[5]], [payoffs[6], payoffs[7]]],
89
- ], dtype=np.int32)
90
-
91
-
92
- def _matrix_html(payoffs, equilibria):
93
- ne_set = set(equilibria)
94
- rows_html = []
95
- for r in range(2):
96
- row_cells = []
97
- for c in range(2):
98
- idx = r * 2 + c
99
- p1, p2 = payoffs[idx * 2], payoffs[idx * 2 + 1]
100
- is_ne = (r, c) in ne_set
101
- accent = f&quot;color:{ACCENT};font-weight:600;&quot; if is_ne else f&quot;color:{TEXT_MUT};&quot;
102
- star = &quot; ★&quot; if is_ne else &quot;&quot;
103
- row_cells.append(
104
- f&quot;&lt;td style=&#x27;padding:14px 22px;border:1px solid {BORDER_STRONG};&quot;
105
- f&quot;background:{BG_ALT};text-align:center;{accent}&#x27;&gt;&quot;
106
- f&quot;({p1}, {p2}){star}&lt;/td&gt;&quot;
107
- )
108
- rows_html.append(&quot;&lt;tr&gt;&quot; + &quot;&quot;.join(row_cells) + &quot;&lt;/tr&gt;&quot;)
109
-
110
- header = (
111
- f&quot;&lt;tr&gt;&quot;
112
- f&quot;&lt;th style=&#x27;padding:8px 22px;background:{BG_PAGE};color:{TEXT_DIM};&quot;
113
- f&quot;font-size:0.75rem;text-transform:uppercase;border:1px solid {BORDER_STRONG}&#x27;&gt;Col 0&lt;/th&gt;&quot;
114
- f&quot;&lt;th style=&#x27;padding:8px 22px;background:{BG_PAGE};color:{TEXT_DIM};&quot;
115
- f&quot;font-size:0.75rem;text-transform:uppercase;border:1px solid {BORDER_STRONG}&#x27;&gt;Col 1&lt;/th&gt;&quot;
116
- f&quot;&lt;/tr&gt;&quot;
117
- )
118
- return (
119
- f&quot;&lt;table style=&#x27;border-collapse:collapse;font-family:IBM Plex Mono,monospace;&quot;
120
- f&quot;font-size:1rem;margin:auto;&#x27;&gt;{header}{&#x27;&#x27;.join(rows_html)}&lt;/table&gt;&quot;
121
- )
122
-
123
-
124
- def compute_nash(r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
125
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2):
126
- payoffs = _parse([r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
127
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2])
128
- matrix = _build_matrix(payoffs)
129
- eq = find_nash_equilibria(matrix)
130
-
131
- table_html = _matrix_html(payoffs, eq)
132
-
133
- if not eq:
134
- props, _ = classify_full(matrix, [])
135
- if props[&quot;mixed_exists&quot;]:
136
- p_val, q_val = props[&quot;mixed_p&quot;], props[&quot;mixed_q&quot;]
137
- ep1, ep2 = props[&quot;mixed_payoff_p1&quot;], props[&quot;mixed_payoff_p2&quot;]
138
- desc = (
139
- f&quot;&lt;p style=&#x27;color:{TEXT_MUT};font-family:IBM Plex Mono,monospace;&#x27;&gt;&quot;
140
- f&quot;⚠ &lt;strong style=&#x27;color:{TEXT_PRI}&#x27;&gt;No pure-strategy Nash equilibrium.&lt;/strong&gt;&lt;br&gt;&quot;
141
- f&quot;Mixed-strategy NE: P1 plays Row&amp;nbsp;0 with probability &quot;
142
- f&quot;&lt;strong style=&#x27;color:{ACCENT}&#x27;&gt;p&amp;nbsp;=&amp;nbsp;{p_val:.4f}&lt;/strong&gt;, &quot;
143
- f&quot;P2 plays Col&amp;nbsp;0 with probability &quot;
144
- f&quot;&lt;strong style=&#x27;color:{ACCENT}&#x27;&gt;q&amp;nbsp;=&amp;nbsp;{q_val:.4f}&lt;/strong&gt;.&lt;br&gt;&quot;
145
- f&quot;Expected payoffs: ({ep1:.3f},&amp;nbsp;{ep2:.3f}).&lt;/p&gt;&quot;
146
- )
147
- else:
148
- desc = (
149
- f&quot;&lt;p style=&#x27;color:{TEXT_MUT};font-family:IBM Plex Mono,monospace;&#x27;&gt;&quot;
150
- f&quot;⚠ &lt;strong style=&#x27;color:{TEXT_PRI}&#x27;&gt;No pure-strategy Nash equilibrium.&lt;/strong&gt; &quot;
151
- f&quot;Mixed NE is degenerate for this matrix.&lt;/p&gt;&quot;
152
- )
153
- elif len(eq) == 1:
154
- r, c = eq[0]
155
- desc = (
156
- f&quot;&lt;p style=&#x27;font-family:IBM Plex Mono,monospace;&#x27;&gt;&quot;
157
- f&quot;✓ &lt;strong style=&#x27;color:{ACCENT}&#x27;&gt;1 Nash equilibrium&lt;/strong&gt; &quot;
158
- f&quot;at position ({r}, {c}) — payoffs ({matrix[r,c,0]}, {matrix[r,c,1]}).&lt;/p&gt;&quot;
159
- )
160
- else:
161
- pos_str = &quot;, &quot;.join(f&quot;({r},{c})&quot; for r, c in eq)
162
- desc = (
163
- f&quot;&lt;p style=&#x27;font-family:IBM Plex Mono,monospace;&#x27;&gt;&quot;
164
- f&quot;✓ &lt;strong style=&#x27;color:{ACCENT}&#x27;&gt;{len(eq)} Nash equilibria&lt;/strong&gt; &quot;
165
- f&quot;at positions: {pos_str}.&lt;/p&gt;&quot;
166
- )
167
-
168
- return (
169
- f&quot;&lt;div style=&#x27;text-align:center;padding:1.5rem;&#x27;&gt;&quot;
170
- f&quot;{table_html}&quot;
171
- f&quot;&lt;div style=&#x27;margin-top:1rem;&#x27;&gt;{desc}&lt;/div&gt;&quot;
172
- f&quot;&lt;/div&gt;&quot;
173
- )
174
-
175
-
176
- def classify_matrix(r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
177
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2):
178
- payoffs = _parse([r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
179
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2])
180
- matrix = _build_matrix(payoffs)
181
- ne = find_nash_equilibria(matrix)
182
- props, label = classify_full(matrix, ne)
183
-
184
- def _bool_badge(v):
185
- color = ACCENT if v else TEXT_DIM
186
- symbol = &quot;✓&quot; if v else &quot;✗&quot;
187
- return (f&quot;&lt;span style=&#x27;color:{color};font-weight:600;&quot;
188
- f&quot;font-family:IBM Plex Mono,monospace&#x27;&gt;{symbol}&lt;/span&gt;&quot;)
189
-
190
- def _row(name, val_html, note=&quot;&quot;):
191
- return (
192
- f&quot;&lt;tr&gt;&quot;
193
- f&quot;&lt;td style=&#x27;padding:6px 14px;color:{TEXT_MUT};font-size:0.82rem;&quot;
194
- f&quot;font-family:IBM Plex Mono,monospace;border-bottom:1px solid {BORDER_STRONG}&#x27;&gt;{name}&lt;/td&gt;&quot;
195
- f&quot;&lt;td style=&#x27;padding:6px 14px;text-align:center;&quot;
196
- f&quot;border-bottom:1px solid {BORDER_STRONG}&#x27;&gt;{val_html}&lt;/td&gt;&quot;
197
- f&quot;&lt;td style=&#x27;padding:6px 14px;color:{TEXT_DIM};font-size:0.78rem;&quot;
198
- f&quot;border-bottom:1px solid {BORDER_STRONG}&#x27;&gt;{note}&lt;/td&gt;&quot;
199
- f&quot;&lt;/tr&gt;&quot;
200
- )
201
-
202
- ne_str = str(ne) if ne else &quot;none&quot;
203
- nw_str = str(props[&quot;ne_welfare&quot;]) if props[&quot;ne_welfare&quot;] else &quot;—&quot;
204
-
205
- prop_rows = [
206
- _row(&quot;P1 has dominant strategy&quot;, _bool_badge(props[&quot;p1_has_dominant&quot;]),
207
- &quot;a row weakly best in every column&quot;),
208
- _row(&quot;P2 has dominant strategy&quot;, _bool_badge(props[&quot;p2_has_dominant&quot;]),
209
- &quot;a column weakly best in every row&quot;),
210
- _row(&quot;Both dominant&quot;, _bool_badge(props[&quot;both_dominant&quot;])),
211
- _row(&quot;Zero-sum&quot;, _bool_badge(props[&quot;is_zero_sum&quot;]),
212
- &quot;p1+p2 constant across all cells&quot;),
213
- _row(&quot;Symmetric&quot;, _bool_badge(props[&quot;is_symmetric&quot;]),
214
- &quot;matrix[r,c,0] == matrix[c,r,1]&quot;),
215
- _row(&quot;NE count&quot;,
216
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-weight:600;&quot;
217
- f&quot;font-family:IBM Plex Mono,monospace&#x27;&gt;{props[&#x27;ne_count&#x27;]}&lt;/span&gt;&quot;,
218
- ne_str),
219
- _row(&quot;Any NE Pareto-dominated&quot;, _bool_badge(props[&quot;has_pareto_dom_ne&quot;]),
220
- &quot;some other cell beats the NE for both players&quot;),
221
- _row(&quot;All NE Pareto-efficient&quot;, _bool_badge(props[&quot;all_ne_pareto_eff&quot;])),
222
- _row(&quot;Max social welfare&quot;,
223
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
224
- f&quot;{props[&#x27;max_welfare&#x27;]}&lt;/span&gt;&quot;, &quot;best possible p1+p2&quot;),
225
- _row(&quot;NE welfare&quot;,
226
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
227
- f&quot;{nw_str}&lt;/span&gt;&quot;),
228
- _row(&quot;Welfare loss&quot;,
229
- f&quot;&lt;span style=&#x27;color:{&#x27;#C0392B&#x27; if props[&#x27;welfare_loss&#x27;] &gt; 0 else &#x27;#4CAF82&#x27;};&quot;
230
- f&quot;font-weight:600;font-family:IBM Plex Mono,monospace&#x27;&gt;{props[&#x27;welfare_loss&#x27;]}&lt;/span&gt;&quot;,
231
- &quot;max_welfare − best NE welfare&quot;),
232
- ]
233
-
234
- if props[&quot;ne_count&quot;] &gt; 0:
235
- mean_diff_str = (f&quot;{props[&#x27;ne_mean_abs_diff&#x27;]:.3f}&quot;
236
- if props[&quot;ne_mean_abs_diff&quot;] is not None else &quot;—&quot;)
237
- prop_rows += [
238
- _row(&quot;NE payoffs — P1&quot;,
239
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
240
- f&quot;{props[&#x27;ne_p1_payoffs&#x27;]}&lt;/span&gt;&quot;, &quot;per equilibrium&quot;),
241
- _row(&quot;NE payoffs — P2&quot;,
242
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
243
- f&quot;{props[&#x27;ne_p2_payoffs&#x27;]}&lt;/span&gt;&quot;, &quot;per equilibrium&quot;),
244
- _row(&quot;Payoff diff (P1−P2)&quot;,
245
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
246
- f&quot;{props[&#x27;ne_payoff_diffs&#x27;]}&lt;/span&gt;&quot;, &quot;per equilibrium&quot;),
247
- _row(&quot;Any NE with equal payoffs&quot;, _bool_badge(props[&quot;ne_has_equal_payoffs&quot;]),
248
- &quot;P1 == P2 at some NE&quot;),
249
- _row(&quot;Mean |P1−P2| at NE&quot;,
250
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
251
- f&quot;{mean_diff_str}&lt;/span&gt;&quot;, &quot;payoff asymmetry&quot;),
252
- ]
253
-
254
- if props[&quot;ne_count&quot;] == 0:
255
- if props[&quot;mixed_exists&quot;]:
256
- prop_rows += [
257
- _row(&quot;Mixed strategy — P1 plays Row 0&quot;,
258
- f&quot;&lt;span style=&#x27;color:{ACCENT};font-weight:600;&quot;
259
- f&quot;font-family:IBM Plex Mono,monospace&#x27;&gt;p = {props[&#x27;mixed_p&#x27;]:.4f}&lt;/span&gt;&quot;,
260
- &quot;probability ∈ [0, 1]&quot;),
261
- _row(&quot;Mixed strategy — P2 plays Col 0&quot;,
262
- f&quot;&lt;span style=&#x27;color:{ACCENT};font-weight:600;&quot;
263
- f&quot;font-family:IBM Plex Mono,monospace&#x27;&gt;q = {props[&#x27;mixed_q&#x27;]:.4f}&lt;/span&gt;&quot;,
264
- &quot;probability ∈ [0, 1]&quot;),
265
- _row(&quot;Expected payoffs at mixed NE&quot;,
266
- f&quot;&lt;span style=&#x27;color:{TEXT_PRI};font-family:IBM Plex Mono,monospace&#x27;&gt;&quot;
267
- f&quot;({props[&#x27;mixed_payoff_p1&#x27;]:.3f}, {props[&#x27;mixed_payoff_p2&#x27;]:.3f})&lt;/span&gt;&quot;,
268
- &quot;(P1, P2)&quot;),
269
- ]
270
- else:
271
- prop_rows.append(
272
- _row(&quot;Mixed strategy&quot;,
273
- f&quot;&lt;span style=&#x27;color:{TEXT_DIM}&#x27;&gt;none (degenerate)&lt;/span&gt;&quot;,
274
- &quot;denominator = 0&quot;)
275
- )
276
-
277
- props_html = (
278
- f&quot;&lt;div style=&#x27;background:{BG_SURFACE};border:1px solid {BORDER_STRONG};&quot;
279
- f&quot;border-radius:6px;overflow:hidden;margin-top:0.5rem&#x27;&gt;&quot;
280
- f&quot;&lt;table style=&#x27;width:100%;border-collapse:collapse&#x27;&gt;{&#x27;&#x27;.join(prop_rows)}&lt;/table&gt;&lt;/div&gt;&quot;
281
- )
282
-
283
- badge_color = GAME_TYPE_COLORS.get(label, TEXT_MUT)
284
- game_type_html = (
285
- f&quot;&lt;div style=&#x27;text-align:center;padding:1.5rem 1rem&#x27;&gt;&quot;
286
- f&quot;&lt;div style=&#x27;font-family:IBM Plex Mono,monospace;font-size:0.72rem;&quot;
287
- f&quot;color:{TEXT_DIM};text-transform:uppercase;letter-spacing:0.12em;&quot;
288
- f&quot;margin-bottom:0.6rem&#x27;&gt;Game Type&lt;/div&gt;&quot;
289
- f&quot;&lt;div style=&#x27;font-family:Space Grotesk,sans-serif;font-weight:700;&quot;
290
- f&quot;font-size:2rem;color:{badge_color};letter-spacing:-0.01em&#x27;&gt;{label}&lt;/div&gt;&quot;
291
- f&quot;&lt;/div&gt;&quot;
292
- )
293
-
294
- desc = GAME_TYPE_DESCRIPTIONS.get(label, &quot;&quot;)
295
- desc_html = (
296
- f&quot;&lt;div style=&#x27;background:{BG_ALT};border-left:3px solid {badge_color};&quot;
297
- f&quot;padding:1rem 1.2rem;border-radius:0 6px 6px 0;margin-top:0.5rem;&quot;
298
- f&quot;font-family:IBM Plex Mono,monospace;font-size:0.84rem;&quot;
299
- f&quot;color:{TEXT_MUT};line-height:1.6&#x27;&gt;{desc}&lt;/div&gt;&quot;
300
- ) if desc else &quot;&quot;
301
-
302
- return props_html, game_type_html, desc_html
303
-
304
-
305
- def build_app():
306
- with gr.Blocks(title=&quot;Game Theory Matrix Classifier&quot;) as demo:
307
- gr.HTML(&quot;&quot;&quot;
308
- &lt;div style=&quot;padding:1.8rem 1rem 0.5rem;border-bottom:1px solid #3D2418;&quot;&gt;
309
- &lt;div style=&quot;font-family:&#x27;Space Grotesk&#x27;,sans-serif;font-weight:700;
310
- font-size:1.5rem;text-transform:uppercase;
311
- letter-spacing:-0.02em;color:#F0EDE8;&quot;&gt;
312
- Game Theory Matrix Classifier
313
- &lt;/div&gt;
314
- &lt;div style=&quot;font-family:&#x27;IBM Plex Mono&#x27;,monospace;font-size:0.78rem;
315
- color:#7A7570;margin-top:0.25rem;&quot;&gt;
316
- Compact 2x2 game-theory demo · equilibria, mixed strategies, and classic game labels
317
- &lt;/div&gt;
318
- &lt;/div&gt;
319
- &quot;&quot;&quot;)
320
-
321
- gr.Markdown(&quot;&quot;&quot;
322
- This public Space is the lightweight front-facing slice of a larger local research project.
323
- It focuses on one thing: helping you explore **2x2 payoff matrices** without exposing notebooks,
324
- write-ups, or large local datasets.
325
-
326
- What you can do here:
327
-
328
- 1. Enter any 2x2 payoff matrix and find its pure-strategy Nash equilibria
329
- 2. See the mixed-strategy equilibrium when no pure one exists
330
- 3. Classify the matrix into familiar game types such as
331
- `Prisoner&#x27;s Dilemma`, `Battle of the Sexes`, `Stag Hunt`, `Chicken`,
332
- `Coordination`, `Zero-Sum`, and more
333
-
334
- Use **Tab 01** for fast equilibrium checks. Use **Tab 02** for the fuller structural readout:
335
- dominant strategies, Pareto efficiency, welfare loss, asymmetry at equilibrium, and the
336
- game-type label.
337
- &quot;&quot;&quot;)
338
-
339
- with gr.Tabs():
340
- with gr.Tab(&quot;01 / MATRIX EXPLORER&quot;):
341
- gr.Markdown(
342
- &quot;&gt; Enter any 2x2 payoff matrix. Each cell takes two numbers: &quot;
343
- &quot;**Player 1&#x27;s payoff** and **Player 2&#x27;s payoff**. Hit **Compute** &quot;
344
- &quot;to find all pure-strategy Nash equilibria instantly.\n\n&quot;
345
- &quot;Or load a classic game from the presets.&quot;
346
- )
347
-
348
- with gr.Row():
349
- preset_dd = gr.Dropdown(
350
- choices=list(PRESETS.keys()),
351
- label=&quot;Load a classic game&quot;,
352
- value=None,
353
- )
354
-
355
- gr.HTML(
356
- &quot;&lt;div style=&#x27;padding:0.4rem 0;color:#4A4540;font-size:0.75rem;&quot;
357
- &quot;font-family:IBM Plex Mono,monospace;&#x27;&gt;&quot;
358
- &quot;Row labels → Row 0 / Row 1 &amp;nbsp;·&amp;nbsp; &quot;
359
- &quot;Column labels → Col 0 / Col 1&lt;/div&gt;&quot;
360
- )
361
-
362
- with gr.Row():
363
- with gr.Column():
364
- gr.HTML(&quot;&lt;div class=&#x27;section-num&#x27;&gt;TOP ROW&lt;/div&gt;&quot;)
365
- with gr.Row():
366
- r0c0_p1 = gr.Number(label=&quot;(0,0) P1&quot;, value=3, precision=0)
367
- r0c0_p2 = gr.Number(label=&quot;(0,0) P2&quot;, value=3, precision=0)
368
- with gr.Row():
369
- r0c1_p1 = gr.Number(label=&quot;(0,1) P1&quot;, value=0, precision=0)
370
- r0c1_p2 = gr.Number(label=&quot;(0,1) P2&quot;, value=5, precision=0)
371
- with gr.Column():
372
- gr.HTML(&quot;&lt;div class=&#x27;section-num&#x27;&gt;BOTTOM ROW&lt;/div&gt;&quot;)
373
- with gr.Row():
374
- r1c0_p1 = gr.Number(label=&quot;(1,0) P1&quot;, value=5, precision=0)
375
- r1c0_p2 = gr.Number(label=&quot;(1,0) P2&quot;, value=0, precision=0)
376
- with gr.Row():
377
- r1c1_p1 = gr.Number(label=&quot;(1,1) P1&quot;, value=1, precision=0)
378
- r1c1_p2 = gr.Number(label=&quot;(1,1) P2&quot;, value=1, precision=0)
379
-
380
- compute_btn = gr.Button(&quot;⚡ Compute Nash Equilibria&quot;, variant=&quot;primary&quot;)
381
- output_html = gr.HTML()
382
-
383
- all_inputs = [r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
384
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2]
385
-
386
- compute_btn.click(fn=compute_nash, inputs=all_inputs, outputs=output_html)
387
- preset_dd.change(fn=load_preset, inputs=preset_dd, outputs=all_inputs)
388
- demo.load(fn=compute_nash, inputs=all_inputs, outputs=output_html)
389
-
390
- with gr.Tab(&quot;02 / CLASSIFICATION&quot;):
391
- gr.Markdown(
392
- &quot;&gt; Classify any 2x2 matrix: game type label, boolean structural &quot;
393
- &quot;properties, Pareto efficiency, welfare loss, and mixed-strategy &quot;
394
- &quot;equilibrium when no pure NE exists.&quot;
395
- )
396
-
397
- with gr.Row():
398
- cl_preset_dd = gr.Dropdown(
399
- choices=list(PRESETS.keys()),
400
- label=&quot;Load a classic game&quot;,
401
- value=&quot;Prisoner&#x27;s Dilemma&quot;,
402
- )
403
-
404
- with gr.Row():
405
- with gr.Column():
406
- gr.HTML(&quot;&lt;div class=&#x27;section-num&#x27;&gt;TOP ROW&lt;/div&gt;&quot;)
407
- with gr.Row():
408
- c2_r0c0_p1 = gr.Number(label=&quot;(0,0) P1&quot;, value=3, precision=0)
409
- c2_r0c0_p2 = gr.Number(label=&quot;(0,0) P2&quot;, value=3, precision=0)
410
- with gr.Row():
411
- c2_r0c1_p1 = gr.Number(label=&quot;(0,1) P1&quot;, value=0, precision=0)
412
- c2_r0c1_p2 = gr.Number(label=&quot;(0,1) P2&quot;, value=5, precision=0)
413
- with gr.Column():
414
- gr.HTML(&quot;&lt;div class=&#x27;section-num&#x27;&gt;BOTTOM ROW&lt;/div&gt;&quot;)
415
- with gr.Row():
416
- c2_r1c0_p1 = gr.Number(label=&quot;(1,0) P1&quot;, value=5, precision=0)
417
- c2_r1c0_p2 = gr.Number(label=&quot;(1,0) P2&quot;, value=0, precision=0)
418
- with gr.Row():
419
- c2_r1c1_p1 = gr.Number(label=&quot;(1,1) P1&quot;, value=1, precision=0)
420
- c2_r1c1_p2 = gr.Number(label=&quot;(1,1) P2&quot;, value=1, precision=0)
421
-
422
- classify_btn = gr.Button(&quot;🔍 Classify Game&quot;, variant=&quot;primary&quot;)
423
-
424
- c2_inputs = [c2_r0c0_p1, c2_r0c0_p2, c2_r0c1_p1, c2_r0c1_p2,
425
- c2_r1c0_p1, c2_r1c0_p2, c2_r1c1_p1, c2_r1c1_p2]
426
-
427
- c2_game_type_html = gr.HTML()
428
- c2_desc_html = gr.HTML()
429
- c2_props_html = gr.HTML()
430
-
431
- classify_btn.click(
432
- fn=classify_matrix,
433
- inputs=c2_inputs,
434
- outputs=[c2_props_html, c2_game_type_html, c2_desc_html],
435
- )
436
- cl_preset_dd.change(fn=load_preset, inputs=cl_preset_dd, outputs=c2_inputs)
437
- demo.load(
438
- fn=classify_matrix,
439
- inputs=c2_inputs,
440
- outputs=[c2_props_html, c2_game_type_html, c2_desc_html],
441
- )
442
- return demo
443
-
444
-
445
- if __name__ == &quot;__main__&quot;:
446
- app = build_app()
447
- app.launch(theme=gr.themes.Base(), css=GRADIO_CSS)
448
-
449
- </gradio-file><gradio-file name="src/__init__.py">
450
-
451
- </gradio-file><gradio-file name="src/analysis.py">
452
- &quot;&quot;&quot;
453
- analysis.py — Game type classification for 2-player payoff matrices.
454
-
455
- Two-layer approach:
456
- Layer 1 — classify_properties(matrix) → dict of boolean/numeric flags
457
- Layer 2 — classify_game_type(props, ne_positions) → human-readable string label
458
-
459
- Both layers work on a single matrix (numpy array, shape (R, C, 2)).
460
- For bulk classification of large datasets, use classify_batch() which processes
461
- a numpy batch (N, R, C, 2) in vectorised operations where possible.
462
-
463
- Named game types (in priority order):
464
- &quot;Zero-Sum&quot; — payoffs sum to constant in every cell
465
- &quot;Prisoner&#x27;s Dilemma&quot; — both dominant strategies, NE is Pareto-dominated
466
- &quot;Harmony&quot; — both dominant strategies, NE is Pareto-efficient
467
- &quot;Deadlock&quot; — both dominant strategies, inefficient but not PD
468
- &quot;Battle of the Sexes&quot; — 2 diagonal NE, players prefer different ones
469
- &quot;Stag Hunt&quot; — symmetric coordination with a payoff-dominant diagonal
470
- &quot;Chicken&quot; — symmetric anti-coordination with off-diagonal NE
471
- &quot;Coordination&quot; — ≥2 NE with coordination structure not covered above
472
- &quot;Dominant (P1 only)&quot; — only Player 1 has dominant strategy
473
- &quot;Dominant (P2 only)&quot; — only Player 2 has dominant strategy
474
- &quot;No Equilibrium&quot; — no pure-strategy NE
475
- &quot;Other&quot; — everything else
476
-
477
- Usage
478
- -----
479
- from src.analysis import classify_properties, classify_game_type
480
- from src.matrix_permutations import find_nash_equilibria
481
- import numpy as np
482
-
483
- m = np.array([[[3,3],[0,5]],[[5,0],[1,1]]], dtype=np.int32)
484
- ne = find_nash_equilibria(m)
485
- props = classify_properties(m, ne)
486
- label = classify_game_type(props, ne) # → &quot;Prisoner&#x27;s Dilemma&quot;
487
- &quot;&quot;&quot;
488
-
489
- from __future__ import annotations
490
- from typing import Dict, List, Tuple, Any
491
- import numpy as np
492
-
493
-
494
- # ---------------------------------------------------------------------------
495
- # Type alias
496
- # ---------------------------------------------------------------------------
497
-
498
- Props = Dict[str, Any]
499
-
500
-
501
- # ---------------------------------------------------------------------------
502
- # Layer 1: Boolean + numeric properties for a single matrix
503
- # ---------------------------------------------------------------------------
504
-
505
- def _has_dominant_strategy(matrix: np.ndarray, player: int) -&gt; bool:
506
- &quot;&quot;&quot;
507
- Return True if `player` (0=row, 1=col) has a weakly dominant strategy.
508
-
509
- A strategy s* weakly dominates all others if, for every possible opponent
510
- strategy, s* gives at least as good a payoff as any other choice.
511
-
512
- For the row player: there exists row r* such that
513
- matrix[r*, c, 0] &gt;= matrix[r, c, 0] for ALL r, c
514
-
515
- For the column player: there exists col c* such that
516
- matrix[r, c*, 1] &gt;= matrix[r, c, 1] for ALL r, c
517
- &quot;&quot;&quot;
518
- R, C, _ = matrix.shape
519
- payoffs = matrix[:, :, player] # (R, C)
520
-
521
- if player == 0: # row player — look for a dominant row
522
- for r_star in range(R):
523
- if np.all(payoffs[r_star, :] &gt;= payoffs):
524
- return True
525
- else: # col player — look for a dominant column
526
- for c_star in range(C):
527
- if np.all(payoffs[:, c_star][:, None] &gt;= payoffs):
528
- return True
529
- return False
530
-
531
-
532
- def _pareto_dominated(cell_r: int, cell_c: int, matrix: np.ndarray) -&gt; bool:
533
- &quot;&quot;&quot;
534
- Return True if cell (cell_r, cell_c) is Pareto-dominated by any other cell.
535
-
536
- A cell X is Pareto-dominated by cell Y if:
537
- Y gives at least as much to both players, and strictly more to at least one.
538
- &quot;&quot;&quot;
539
- p1_here = int(matrix[cell_r, cell_c, 0])
540
- p2_here = int(matrix[cell_r, cell_c, 1])
541
- R, C, _ = matrix.shape
542
-
543
- for r in range(R):
544
- for c in range(C):
545
- if r == cell_r and c == cell_c:
546
- continue
547
- p1_there = int(matrix[r, c, 0])
548
- p2_there = int(matrix[r, c, 1])
549
- if p1_there &gt;= p1_here and p2_there &gt;= p2_here:
550
- if p1_there &gt; p1_here or p2_there &gt; p2_here:
551
- return True
552
- return False
553
-
554
-
555
- def _compute_mixed_strategy_2x2(matrix: np.ndarray) -&gt; Dict[str, Any]:
556
- &quot;&quot;&quot;
557
- Compute the mixed-strategy Nash equilibrium for a 2×2 payoff matrix.
558
-
559
- Layout:
560
- Row 0: [(a, e), (b, f)] → matrix[0,0]=(a,e), matrix[0,1]=(b,f)
561
- Row 1: [(c, g), (d, h)] → matrix[1,0]=(c,g), matrix[1,1]=(d,h)
562
-
563
- P1 mixes with probability p (plays Row 0), making P2 indifferent:
564
- p·e + (1−p)·g = p·f + (1−p)·h → p = (h−g) / (e−g−f+h)
565
-
566
- P2 mixes with probability q (plays Col 0), making P1 indifferent:
567
- q·a + (1−q)·b = q·c + (1−q)·d → q = (d−b) / (a−b−c+d)
568
-
569
- Returns a dict with keys:
570
- mixed_exists bool
571
- mixed_p float | None P1 plays Row 0 with this probability
572
- mixed_q float | None P2 plays Col 0 with this probability
573
- mixed_payoff_p1 float | None P1 expected payoff at mixed NE
574
- mixed_payoff_p2 float | None P2 expected payoff at mixed NE
575
- &quot;&quot;&quot;
576
- _null = {&quot;mixed_exists&quot;: False, &quot;mixed_p&quot;: None, &quot;mixed_q&quot;: None,
577
- &quot;mixed_payoff_p1&quot;: None, &quot;mixed_payoff_p2&quot;: None}
578
-
579
- if matrix.shape != (2, 2, 2):
580
- return _null
581
-
582
- a, e = float(matrix[0, 0, 0]), float(matrix[0, 0, 1])
583
- b, f = float(matrix[0, 1, 0]), float(matrix[0, 1, 1])
584
- c, g = float(matrix[1, 0, 0]), float(matrix[1, 0, 1])
585
- d, h = float(matrix[1, 1, 0]), float(matrix[1, 1, 1])
586
-
587
- denom_p = e - g - f + h # denominator for p
588
- denom_q = a - b - c + d # denominator for q
589
-
590
- if denom_p == 0.0 or denom_q == 0.0:
591
- return _null
592
-
593
- p = (h - g) / denom_p
594
- q = (d - b) / denom_q
595
-
596
- eps = 1e-9
597
- if not (-eps &lt;= p &lt;= 1.0 + eps and -eps &lt;= q &lt;= 1.0 + eps):
598
- return _null
599
-
600
- # Clamp to [0, 1] to absorb floating-point edge cases
601
- p = max(0.0, min(1.0, p))
602
- q = max(0.0, min(1.0, q))
603
-
604
- ep1 = round(q * a + (1.0 - q) * b, 6)
605
- ep2 = round(p * e + (1.0 - p) * g, 6)
606
-
607
- return {
608
- &quot;mixed_exists&quot;: True,
609
- &quot;mixed_p&quot;: round(p, 6),
610
- &quot;mixed_q&quot;: round(q, 6),
611
- &quot;mixed_payoff_p1&quot;: ep1,
612
- &quot;mixed_payoff_p2&quot;: ep2,
613
- }
614
-
615
-
616
- def _ne_payoff_stats(
617
- matrix: np.ndarray,
618
- ne_positions: List[Tuple[int, int]],
619
- ) -&gt; Dict[str, Any]:
620
- &quot;&quot;&quot;
621
- Compute per-NE payoff statistics for a single matrix.
622
-
623
- Returns a dict with keys:
624
- ne_p1_payoffs list[int] — P1 payoff at each NE
625
- ne_p2_payoffs list[int] — P2 payoff at each NE
626
- ne_payoff_diffs list[int] — (P1−P2) at each NE
627
- ne_has_equal_payoffs bool — True if any NE has P1 == P2
628
- ne_mean_abs_diff float|None — mean |P1−P2| across all NE; None if no NE
629
- &quot;&quot;&quot;
630
- if not ne_positions:
631
- return {
632
- &quot;ne_p1_payoffs&quot;: [],
633
- &quot;ne_p2_payoffs&quot;: [],
634
- &quot;ne_payoff_diffs&quot;: [],
635
- &quot;ne_has_equal_payoffs&quot;: False,
636
- &quot;ne_mean_abs_diff&quot;: None,
637
- }
638
-
639
- p1_payoffs = [int(matrix[r, c, 0]) for r, c in ne_positions]
640
- p2_payoffs = [int(matrix[r, c, 1]) for r, c in ne_positions]
641
- diffs = [p1 - p2 for p1, p2 in zip(p1_payoffs, p2_payoffs)]
642
-
643
- return {
644
- &quot;ne_p1_payoffs&quot;: p1_payoffs,
645
- &quot;ne_p2_payoffs&quot;: p2_payoffs,
646
- &quot;ne_payoff_diffs&quot;: diffs,
647
- &quot;ne_has_equal_payoffs&quot;: any(d == 0 for d in diffs),
648
- &quot;ne_mean_abs_diff&quot;: sum(abs(d) for d in diffs) / len(diffs),
649
- }
650
-
651
-
652
- def classify_properties(
653
- matrix: np.ndarray,
654
- ne_positions: List[Tuple[int, int]],
655
- ) -&gt; Props:
656
- &quot;&quot;&quot;
657
- Compute a dictionary of structural properties for a single payoff matrix.
658
-
659
- Parameters
660
- ----------
661
- matrix : np.ndarray, shape (R, C, 2)
662
- ne_positions : list of (row, col) Nash equilibria (from find_nash_equilibria)
663
-
664
- Returns
665
- -------
666
- props : dict with the following keys:
667
-
668
- p1_has_dominant bool — P1 has a weakly dominant strategy
669
- p2_has_dominant bool — P2 has a weakly dominant strategy
670
- both_dominant bool — both players have dominant strategies
671
- is_zero_sum bool — all cells: p1+p2 == constant
672
- is_symmetric bool — matrix[r,c,0] == matrix[c,r,1] (requires R==C)
673
- ne_count int — number of pure-strategy NE
674
- has_pareto_dom_ne bool — any NE is Pareto-dominated by another cell
675
- all_ne_pareto_eff bool — no NE is Pareto-dominated
676
- max_welfare int — max(p1+p2) over all cells
677
- ne_welfare list — p1+p2 at each NE position
678
- welfare_loss int — max_welfare − max(ne_welfare); 0 means NE is optimal
679
-
680
- mixed_exists bool — True if a valid mixed-strategy NE exists
681
- (only computed for 2×2 matrices with 0 pure NE)
682
- mixed_p float|None — P1 plays Row 0 with this probability
683
- mixed_q float|None — P2 plays Col 0 with this probability
684
- mixed_payoff_p1 float|None — P1 expected payoff at mixed NE
685
- mixed_payoff_p2 float|None — P2 expected payoff at mixed NE
686
-
687
- ne_p1_payoffs list[int] — P1 payoff at each NE (empty if no pure NE)
688
- ne_p2_payoffs list[int] — P2 payoff at each NE
689
- ne_payoff_diffs list[int] — (P1−P2) at each NE
690
- ne_has_equal_payoffs bool — True if any NE has P1 == P2
691
- ne_mean_abs_diff float|None — mean |P1−P2| across all NE; None if no pure NE
692
- &quot;&quot;&quot;
693
- R, C, _ = matrix.shape
694
- p1 = matrix[:, :, 0]
695
- p2 = matrix[:, :, 1]
696
- welfare = p1 + p2 # (R, C)
697
-
698
- # Dominant strategies
699
- p1_dom = _has_dominant_strategy(matrix, 0)
700
- p2_dom = _has_dominant_strategy(matrix, 1)
701
-
702
- # Zero-sum: all payoff sums identical
703
- sums = (p1 + p2).ravel()
704
- is_zero_sum = bool(np.all(sums == sums[0]))
705
-
706
- # Symmetry: matrix[r,c,0] == matrix[c,r,1] (only meaningful for square)
707
- if R == C:
708
- is_sym = all(
709
- int(matrix[r, c, 0]) == int(matrix[c, r, 1])
710
- for r in range(R) for c in range(C)
711
- )
712
- else:
713
- is_sym = False
714
-
715
- # NE welfare
716
- ne_w = [int(welfare[r, c]) for r, c in ne_positions]
717
- max_w = int(welfare.max())
718
- max_ne_w = max(ne_w) if ne_w else 0
719
- w_loss = max_w - max_ne_w
720
-
721
- # Pareto efficiency of each NE
722
- ne_pareto_dom = [_pareto_dominated(r, c, matrix) for r, c in ne_positions]
723
- has_pd_ne = any(ne_pareto_dom)
724
- all_pe_ne = not has_pd_ne
725
-
726
- # Mixed-strategy equilibrium — 2×2 matrices with 0 pure NE only
727
- if R == 2 and C == 2 and len(ne_positions) == 0:
728
- mixed = _compute_mixed_strategy_2x2(matrix)
729
- else:
730
- mixed = {&quot;mixed_exists&quot;: False, &quot;mixed_p&quot;: None, &quot;mixed_q&quot;: None,
731
- &quot;mixed_payoff_p1&quot;: None, &quot;mixed_payoff_p2&quot;: None}
732
-
733
- # Payoff asymmetry at Nash equilibria
734
- asym = _ne_payoff_stats(matrix, ne_positions)
735
-
736
- return {
737
- &quot;p1_has_dominant&quot;: p1_dom,
738
- &quot;p2_has_dominant&quot;: p2_dom,
739
- &quot;both_dominant&quot;: p1_dom and p2_dom,
740
- &quot;is_zero_sum&quot;: is_zero_sum,
741
- &quot;is_symmetric&quot;: is_sym,
742
- &quot;ne_count&quot;: len(ne_positions),
743
- &quot;has_pareto_dom_ne&quot;: has_pd_ne,
744
- &quot;all_ne_pareto_eff&quot;: all_pe_ne,
745
- &quot;max_welfare&quot;: max_w,
746
- &quot;ne_welfare&quot;: ne_w,
747
- &quot;welfare_loss&quot;: w_loss,
748
- # Mixed strategy
749
- &quot;mixed_exists&quot;: mixed[&quot;mixed_exists&quot;],
750
- &quot;mixed_p&quot;: mixed[&quot;mixed_p&quot;],
751
- &quot;mixed_q&quot;: mixed[&quot;mixed_q&quot;],
752
- &quot;mixed_payoff_p1&quot;: mixed[&quot;mixed_payoff_p1&quot;],
753
- &quot;mixed_payoff_p2&quot;: mixed[&quot;mixed_payoff_p2&quot;],
754
- # Payoff asymmetry
755
- &quot;ne_p1_payoffs&quot;: asym[&quot;ne_p1_payoffs&quot;],
756
- &quot;ne_p2_payoffs&quot;: asym[&quot;ne_p2_payoffs&quot;],
757
- &quot;ne_payoff_diffs&quot;: asym[&quot;ne_payoff_diffs&quot;],
758
- &quot;ne_has_equal_payoffs&quot;: asym[&quot;ne_has_equal_payoffs&quot;],
759
- &quot;ne_mean_abs_diff&quot;: asym[&quot;ne_mean_abs_diff&quot;],
760
- }
761
-
762
-
763
- # ---------------------------------------------------------------------------
764
- # Layer 2: Named game type (derived from properties)
765
- # ---------------------------------------------------------------------------
766
-
767
- # Plain-English descriptions keyed by type label
768
- GAME_TYPE_DESCRIPTIONS: Dict[str, str] = {
769
- &quot;Zero-Sum&quot;: (
770
- &quot;A zero-sum game: one player&#x27;s gain is exactly the other&#x27;s loss. &quot;
771
- &quot;The total welfare is constant across all outcomes. &quot;
772
- &quot;Classic examples: chess, poker, matching pennies.&quot;
773
- ),
774
- &quot;Prisoner&#x27;s Dilemma&quot;: (
775
- &quot;A social dilemma: both players have a dominant strategy (defect), &quot;
776
- &quot;but the Nash equilibrium leaves both worse off than if they had &quot;
777
- &quot;cooperated. Rational individual behaviour produces a collectively &quot;
778
- &quot;suboptimal result. Welfare loss &gt; 0.&quot;
779
- ),
780
- &quot;Harmony&quot;: (
781
- &quot;A harmony game: both players have dominant strategies AND the &quot;
782
- &quot;Nash equilibrium is Pareto-efficient. Rational self-interest &quot;
783
- &quot;happens to align with the socially optimal outcome — no dilemma.&quot;
784
- ),
785
- &quot;Deadlock&quot;: (
786
- &quot;Both players have dominant strategies leading to an equilibrium, &quot;
787
- &quot;but unlike the Prisoner&#x27;s Dilemma the cooperative outcome is not &quot;
788
- &quot;better for both. Mutual defection is both rational and efficient.&quot;
789
- ),
790
- &quot;Battle of the Sexes&quot;: (
791
- &quot;An asymmetric coordination game with two diagonal equilibria. &quot;
792
- &quot;Both players want to coordinate, but each prefers a different &quot;
793
- &quot;equilibrium, so the main challenge is choosing which outcome to meet at.&quot;
794
- ),
795
- &quot;Stag Hunt&quot;: (
796
- &quot;A symmetric coordination game with one high-reward cooperative &quot;
797
- &quot;equilibrium and one safer fallback equilibrium. Trust matters because &quot;
798
- &quot;failing to coordinate can be costly.&quot;
799
- ),
800
- &quot;Chicken&quot;: (
801
- &quot;A symmetric anti-coordination game with off-diagonal equilibria. &quot;
802
- &quot;Each player wants the other side to yield, creating brinkmanship &quot;
803
- &quot;instead of stable mutual cooperation.&quot;
804
- ),
805
- &quot;Coordination&quot;: (
806
- &quot;A coordination-style game: multiple Nash equilibria exist and the &quot;
807
- &quot;main strategic problem is choosing which stable outcome to coordinate on.&quot;
808
- ),
809
- &quot;Dominant (P1 only)&quot;: (
810
- &quot;Only Player 1 has a dominant strategy. Player 2&#x27;s best response &quot;
811
- &quot;depends on what Player 1 does, but P1 always plays the same way.&quot;
812
- ),
813
- &quot;Dominant (P2 only)&quot;: (
814
- &quot;Only Player 2 has a dominant strategy. Player 1&#x27;s best response &quot;
815
- &quot;depends on what Player 2 does, but P2 always plays the same way.&quot;
816
- ),
817
- &quot;No Equilibrium&quot;: (
818
- &quot;No pure-strategy Nash equilibrium exists. Neither player has a &quot;
819
- &quot;stable resting point; best responses cycle. A mixed-strategy &quot;
820
- &quot;equilibrium always exists (Nash&#x27;s theorem) and is computed for &quot;
821
- &quot;2×2 matrices — see the mixed-strategy properties below.&quot;
822
- ),
823
- &quot;Other&quot;: (
824
- &quot;A game that doesn&#x27;t fit neatly into the classic taxonomy. &quot;
825
- &quot;Neither player has a dominant strategy and there is at least one &quot;
826
- &quot;pure-strategy Nash equilibrium.&quot;
827
- ),
828
- }
829
-
830
-
831
- def _is_diagonal_pair(ne_positions: List[Tuple[int, int]]) -&gt; bool:
832
- return set(ne_positions) == {(0, 0), (1, 1)}
833
-
834
-
835
- def _is_off_diagonal_pair(ne_positions: List[Tuple[int, int]]) -&gt; bool:
836
- return set(ne_positions) == {(0, 1), (1, 0)}
837
-
838
-
839
- def classify_game_type(
840
- props: Props,
841
- ne_positions: List[Tuple[int, int]],
842
- matrix: np.ndarray | None = None,
843
- ) -&gt; str:
844
- &quot;&quot;&quot;
845
- Assign a named game type label based on the properties dict.
846
-
847
- Checked in strict priority order — the first matching rule wins.
848
- &quot;&quot;&quot;
849
- if props[&quot;is_zero_sum&quot;]:
850
- return &quot;Zero-Sum&quot;
851
-
852
- if props[&quot;both_dominant&quot;]:
853
- # True PD: the NE is Pareto-suboptimal AND achieves less than max welfare.
854
- # (welfare_loss &gt; 0 rules out the edge case where one NE Pareto-dominates
855
- # another NE but the dominant NE still achieves max social welfare.)
856
- if props[&quot;has_pareto_dom_ne&quot;] and props[&quot;welfare_loss&quot;] &gt; 0:
857
- return &quot;Prisoner&#x27;s Dilemma&quot;
858
- elif props[&quot;welfare_loss&quot;] == 0:
859
- return &quot;Harmony&quot;
860
- else:
861
- return &quot;Deadlock&quot;
862
-
863
- if matrix is not None and len(ne_positions) == 2 and _is_diagonal_pair(ne_positions):
864
- tl = matrix[0, 0]
865
- br = matrix[1, 1]
866
-
867
- p1_prefers_tl = int(tl[0]) &gt; int(br[0])
868
- p1_prefers_br = int(br[0]) &gt; int(tl[0])
869
- p2_prefers_tl = int(tl[1]) &gt; int(br[1])
870
- p2_prefers_br = int(br[1]) &gt; int(tl[1])
871
-
872
- if (p1_prefers_tl and p2_prefers_br) or (p1_prefers_br and p2_prefers_tl):
873
- return &quot;Battle of the Sexes&quot;
874
-
875
- if props[&quot;is_symmetric&quot;]:
876
- tl_welfare = int(tl[0] + tl[1])
877
- br_welfare = int(br[0] + br[1])
878
- if tl_welfare != br_welfare:
879
- return &quot;Stag Hunt&quot;
880
-
881
- return &quot;Coordination&quot;
882
-
883
- if matrix is not None and len(ne_positions) == 2 and _is_off_diagonal_pair(ne_positions):
884
- if props[&quot;is_symmetric&quot;]:
885
- return &quot;Chicken&quot;
886
-
887
- if len(ne_positions) &gt;= 2 and props[&quot;is_symmetric&quot;]:
888
- return &quot;Coordination&quot;
889
-
890
- if props[&quot;p1_has_dominant&quot;] and not props[&quot;p2_has_dominant&quot;]:
891
- return &quot;Dominant (P1 only)&quot;
892
-
893
- if props[&quot;p2_has_dominant&quot;] and not props[&quot;p1_has_dominant&quot;]:
894
- return &quot;Dominant (P2 only)&quot;
895
-
896
- if props[&quot;ne_count&quot;] == 0:
897
- return &quot;No Equilibrium&quot;
898
-
899
- return &quot;Other&quot;
900
-
901
-
902
- def classify_full(
903
- matrix: np.ndarray,
904
- ne_positions: List[Tuple[int, int]],
905
- ) -&gt; Tuple[Props, str]:
906
- &quot;&quot;&quot;
907
- Convenience wrapper: compute both layers and return (props, game_type).
908
- &quot;&quot;&quot;
909
- props = classify_properties(matrix, ne_positions)
910
- label = classify_game_type(props, ne_positions, matrix)
911
- return props, label
912
-
913
-
914
- # ---------------------------------------------------------------------------
915
- # Bulk classification (used by enrich_datasets.py)
916
- # ---------------------------------------------------------------------------
917
-
918
- def classify_rows_batch(
919
- matrices_flat: np.ndarray,
920
- ne_positions_list: List[List[Tuple[int, int]]],
921
- rows: int,
922
- cols: int,
923
- ) -&gt; List[Dict[str, Any]]:
924
- &quot;&quot;&quot;
925
- Classify a list of matrices given their pre-computed NE positions.
926
-
927
- Parameters
928
- ----------
929
- matrices_flat : (N, rows*cols*2) array of int — payoff values flat
930
- ne_positions_list : list of length N, each element a list of (r,c) NE positions
931
- rows, cols : matrix dimensions
932
-
933
- Returns
934
- -------
935
- List of dicts, one per matrix, with keys:
936
- p1_has_dominant, p2_has_dominant, both_dominant, is_zero_sum,
937
- is_symmetric, has_pareto_dom_ne, all_ne_pareto_eff,
938
- max_welfare, ne_welfare, welfare_loss,
939
- mixed_exists, mixed_p, mixed_q, mixed_payoff_p1, mixed_payoff_p2,
940
- ne_p1_payoffs, ne_p2_payoffs, ne_payoff_diffs,
941
- ne_has_equal_payoffs, ne_mean_abs_diff,
942
- game_type
943
- &quot;&quot;&quot;
944
- results = []
945
- for i, flat in enumerate(matrices_flat):
946
- matrix = flat.reshape(rows, cols, 2)
947
- ne = ne_positions_list[i]
948
- props, label = classify_full(matrix, ne)
949
- results.append({**props, &quot;game_type&quot;: label})
950
- return results
951
-
952
- </gradio-file><gradio-file name="src/matrix_permutations.py">
953
- import itertools
954
- import numpy as np
955
- import csv
956
- from typing import List, Tuple, Iterator, Generator
957
-
958
-
959
- # ---------------------------------------------------------------------------
960
- # Core n×m generation
961
- # ---------------------------------------------------------------------------
962
-
963
- def generate_dual_matrices(rows: int, cols: int, min_val: int = 0, max_val: int = 5) -&gt; List[np.ndarray]:
964
- &quot;&quot;&quot;
965
- Generate ALL possible n×m matrices where every cell holds a (player1, player2)
966
- payoff pair. Both payoffs range from min_val to max_val inclusive.
967
-
968
- Returns a list of numpy arrays with shape (rows, cols, 2).
969
-
970
- Use generate_dual_matrices_iter() instead when the full list would be too
971
- large to hold in RAM (e.g. 0-10 range with 214M matrices).
972
- &quot;&quot;&quot;
973
- values = range(min_val, max_val + 1)
974
- position_pairs = list(itertools.product(values, values))
975
- num_cells = rows * cols
976
-
977
- matrices = []
978
- for perm in itertools.product(position_pairs, repeat=num_cells):
979
- matrix = np.array(perm, dtype=np.int32).reshape(rows, cols, 2)
980
- matrices.append(matrix)
981
- return matrices
982
-
983
-
984
- def generate_dual_matrices_iter(
985
- rows: int, cols: int, min_val: int = 0, max_val: int = 5
986
- ) -&gt; Iterator[np.ndarray]:
987
- &quot;&quot;&quot;
988
- Generator version of generate_dual_matrices().
989
- Yields one (rows, cols, 2) numpy array at a time — never holds the full
990
- dataset in memory. Use this for large ranges (e.g. 0–10).
991
- &quot;&quot;&quot;
992
- values = range(min_val, max_val + 1)
993
- position_pairs = list(itertools.product(values, values))
994
- num_cells = rows * cols
995
-
996
- for perm in itertools.product(position_pairs, repeat=num_cells):
997
- yield np.array(perm, dtype=np.int32).reshape(rows, cols, 2)
998
-
999
-
1000
- def generate_dual_matrices_batched(
1001
- rows: int,
1002
- cols: int,
1003
- min_val: int = 0,
1004
- max_val: int = 5,
1005
- batch_size: int = 50_000,
1006
- ) -&gt; Generator[np.ndarray, None, None]:
1007
- &quot;&quot;&quot;
1008
- Yields batches of matrices as a single numpy array of shape (B, rows, cols, 2).
1009
-
1010
- B = batch_size for all batches except possibly the last one.
1011
- This is the most efficient input form for find_nash_batch_vectorized().
1012
-
1013
- Example
1014
- -------
1015
- for batch in generate_dual_matrices_batched(2, 2, 0, 5, batch_size=50_000):
1016
- is_ne, counts = find_nash_batch_vectorized(batch)
1017
- # batch.shape == (50_000, 2, 2, 2) for all but the last chunk
1018
- &quot;&quot;&quot;
1019
- values = range(min_val, max_val + 1)
1020
- position_pairs = list(itertools.product(values, values))
1021
- num_cells = rows * cols
1022
- buf: list[np.ndarray] = []
1023
-
1024
- for perm in itertools.product(position_pairs, repeat=num_cells):
1025
- buf.append(np.array(perm, dtype=np.int32))
1026
- if len(buf) == batch_size:
1027
- yield np.stack(buf).reshape(batch_size, rows, cols, 2)
1028
- buf = []
1029
-
1030
- if buf:
1031
- n = len(buf)
1032
- yield np.stack(buf).reshape(n, rows, cols, 2)
1033
-
1034
-
1035
- # ---------------------------------------------------------------------------
1036
- # Nash equilibrium analysis — single matrix (Python loops)
1037
- # ---------------------------------------------------------------------------
1038
-
1039
- def check_nash_equilibrium(matrix: np.ndarray, position: Tuple[int, int]) -&gt; bool:
1040
- &quot;&quot;&quot;
1041
- Return True if (row, col) is a pure-strategy Nash equilibrium.
1042
-
1043
- A cell is a NE when:
1044
- - The row player cannot improve their payoff by switching to any other row
1045
- (keeping the column fixed).
1046
- - The column player cannot improve their payoff by switching to any other
1047
- column (keeping the row fixed).
1048
-
1049
- Works for any n×m matrix shape.
1050
- &quot;&quot;&quot;
1051
- row, col = position
1052
- current_row_payoff = int(matrix[row, col, 0])
1053
- current_col_payoff = int(matrix[row, col, 1])
1054
-
1055
- # Can the row player do better by moving to a different row?
1056
- for other_row in range(matrix.shape[0]):
1057
- if other_row != row and int(matrix[other_row, col, 0]) &gt; current_row_payoff:
1058
- return False
1059
-
1060
- # Can the column player do better by moving to a different column?
1061
- for other_col in range(matrix.shape[1]):
1062
- if other_col != col and int(matrix[row, other_col, 1]) &gt; current_col_payoff:
1063
- return False
1064
-
1065
- return True
1066
-
1067
-
1068
- def find_nash_equilibria(matrix: np.ndarray) -&gt; List[Tuple[int, int]]:
1069
- &quot;&quot;&quot;
1070
- Find all pure-strategy Nash equilibria in an n×m payoff matrix.
1071
- Returns a (possibly empty) list of (row, col) positions.
1072
- &quot;&quot;&quot;
1073
- equilibria = []
1074
- for row in range(matrix.shape[0]):
1075
- for col in range(matrix.shape[1]):
1076
- if check_nash_equilibrium(matrix, (row, col)):
1077
- equilibria.append((row, col))
1078
- return equilibria
1079
-
1080
-
1081
- # ---------------------------------------------------------------------------
1082
- # Nash equilibrium analysis — vectorised batch (numpy, no Python loops)
1083
- # ---------------------------------------------------------------------------
1084
-
1085
- def find_nash_batch_vectorized(
1086
- batch: np.ndarray,
1087
- ) -&gt; Tuple[np.ndarray, np.ndarray]:
1088
- &quot;&quot;&quot;
1089
- Find pure-strategy Nash equilibria for a BATCH of matrices in one numpy call.
1090
-
1091
- Parameters
1092
- ----------
1093
- batch : np.ndarray, shape (N, rows, cols, 2)
1094
- N matrices, each of shape (rows, cols, 2) where the last axis is
1095
- [player1_payoff, player2_payoff].
1096
-
1097
- Returns
1098
- -------
1099
- is_ne : np.ndarray, shape (N, rows, cols), dtype bool
1100
- True wherever a cell is a Nash equilibrium.
1101
- ne_counts : np.ndarray, shape (N,), dtype int
1102
- Number of Nash equilibria per matrix.
1103
-
1104
- How it works
1105
- ------------
1106
- A cell (r, c) in matrix n is a NE when:
1107
- • P1 cannot strictly improve by switching rows:
1108
- p1[n, r, c] == max over r&#x27; of p1[n, r&#x27;, c]
1109
- • P2 cannot strictly improve by switching columns:
1110
- p2[n, r, c] == max over c&#x27; of p2[n, r, c&#x27;]
1111
-
1112
- Both conditions are expressed as a single element-wise equality after
1113
- broadcasting the per-column and per-row maxima — no Python loops needed.
1114
-
1115
- This is equivalent to find_nash_equilibria() but ~10–15× faster when
1116
- processing large batches (e.g. 50 000 matrices at a time).
1117
- &quot;&quot;&quot;
1118
- p1 = batch[:, :, :, 0] # (N, R, C)
1119
- p2 = batch[:, :, :, 1] # (N, R, C)
1120
-
1121
- # Best P1 can achieve in each column (broadcast over rows dimension)
1122
- p1_col_max = p1.max(axis=1, keepdims=True) # (N, 1, C)
1123
- # Best P2 can achieve in each row (broadcast over cols dimension)
1124
- p2_row_max = p2.max(axis=2, keepdims=True) # (N, R, 1)
1125
-
1126
- # A cell is NE iff both players are already at their column/row maximum
1127
- is_ne = (p1 == p1_col_max) &amp; (p2 == p2_row_max) # (N, R, C)
1128
- ne_counts = is_ne.sum(axis=(1, 2)).astype(np.int32) # (N,)
1129
-
1130
- return is_ne, ne_counts
1131
-
1132
-
1133
- # ---------------------------------------------------------------------------
1134
- # CSV I/O
1135
- # ---------------------------------------------------------------------------
1136
-
1137
- def _build_headers(rows: int, cols: int) -&gt; List[str]:
1138
- &quot;&quot;&quot;Return column headers for a rows×cols payoff matrix CSV.&quot;&quot;&quot;
1139
- headers = []
1140
- for r in range(rows):
1141
- for c in range(cols):
1142
- headers += [f&quot;r{r}c{c}_p1&quot;, f&quot;r{r}c{c}_p2&quot;]
1143
- headers += [&quot;num_equilibria&quot;, &quot;equilibrium_positions&quot;, &quot;category&quot;]
1144
- return headers
1145
-
1146
-
1147
- def save_dual_matrices_to_csv(matrices: List[np.ndarray], filename: str) -&gt; None:
1148
- &quot;&quot;&quot;
1149
- Analyse each matrix for Nash equilibria and write results to a CSV file.
1150
-
1151
- CSV columns (2×2 example):
1152
- r0c0_p1, r0c0_p2, r0c1_p1, r0c1_p2,
1153
- r1c0_p1, r1c0_p2, r1c1_p1, r1c1_p2,
1154
- num_equilibria, equilibrium_positions, category
1155
-
1156
- Args:
1157
- matrices: list of numpy arrays with shape (rows, cols, 2)
1158
- filename: output CSV path
1159
- &quot;&quot;&quot;
1160
- if not matrices:
1161
- return
1162
-
1163
- rows, cols = matrices[0].shape[0], matrices[0].shape[1]
1164
- headers = _build_headers(rows, cols)
1165
-
1166
- with open(filename, &quot;w&quot;, newline=&quot;&quot;) as f:
1167
- writer = csv.writer(f)
1168
- writer.writerow(headers)
1169
- for matrix in matrices:
1170
- _write_matrix_row(writer, matrix)
1171
-
1172
-
1173
- def save_dual_matrices_iter_to_csv(
1174
- matrix_iter: Iterator[np.ndarray],
1175
- filename: str,
1176
- rows: int,
1177
- cols: int,
1178
- progress_interval: int = 1_000_000,
1179
- ) -&gt; int:
1180
- &quot;&quot;&quot;
1181
- Stream matrices from an iterator directly into a CSV — constant RAM usage
1182
- regardless of dataset size. Prints progress every progress_interval rows.
1183
-
1184
- Returns the total number of matrices written.
1185
- &quot;&quot;&quot;
1186
- headers = _build_headers(rows, cols)
1187
- count = 0
1188
-
1189
- with open(filename, &quot;w&quot;, newline=&quot;&quot;) as f:
1190
- writer = csv.writer(f)
1191
- writer.writerow(headers)
1192
- for matrix in matrix_iter:
1193
- _write_matrix_row(writer, matrix)
1194
- count += 1
1195
- if count % progress_interval == 0:
1196
- print(f&quot; {count:,} matrices written…&quot;)
1197
-
1198
- return count
1199
-
1200
-
1201
- def save_batched_to_csv(
1202
- rows: int,
1203
- cols: int,
1204
- min_val: int,
1205
- max_val: int,
1206
- filename: str,
1207
- batch_size: int = 50_000,
1208
- progress_interval: int = 1_000_000,
1209
- ) -&gt; int:
1210
- &quot;&quot;&quot;
1211
- Generate all matrices for (rows, cols, min_val, max_val) and write to CSV
1212
- using batched vectorised NE detection. Streams to disk — constant RAM.
1213
-
1214
- Significantly faster than save_dual_matrices_iter_to_csv() for large ranges.
1215
- Returns total number of matrices written.
1216
- &quot;&quot;&quot;
1217
- headers = _build_headers(rows, cols)
1218
- count = 0
1219
-
1220
- with open(filename, &quot;w&quot;, newline=&quot;&quot;) as f:
1221
- writer = csv.writer(f)
1222
- writer.writerow(headers)
1223
-
1224
- for batch in generate_dual_matrices_batched(rows, cols, min_val, max_val, batch_size):
1225
- is_ne, ne_counts = find_nash_batch_vectorized(batch)
1226
- B = len(batch)
1227
-
1228
- for i in range(B):
1229
- flat = batch[i].reshape(-1).tolist()
1230
- n_eq = int(ne_counts[i])
1231
- positions = [
1232
- (r, c)
1233
- for r in range(rows)
1234
- for c in range(cols)
1235
- if is_ne[i, r, c]
1236
- ]
1237
- category = &quot;Solved&quot; if n_eq &gt; 0 else &quot;Unsolved&quot;
1238
- writer.writerow(
1239
- flat + [n_eq, str(positions) if positions else &quot;None&quot;, category]
1240
- )
1241
- count += 1
1242
- if count % progress_interval == 0:
1243
- print(f&quot; {count:,} matrices written…&quot;)
1244
-
1245
- return count
1246
-
1247
-
1248
- def _write_matrix_row(writer: csv.writer, matrix: np.ndarray) -&gt; None:
1249
- &quot;&quot;&quot;Write one matrix as a CSV row (helper used by both save functions).&quot;&quot;&quot;
1250
- flat_values = matrix.reshape(-1).tolist()
1251
- equilibria = find_nash_equilibria(matrix)
1252
- category = &quot;Solved&quot; if equilibria else &quot;Unsolved&quot;
1253
- writer.writerow(
1254
- flat_values
1255
- + [len(equilibria), str(equilibria) if equilibria else &quot;None&quot;, category]
1256
- )
1257
-
1258
-
1259
- # ---------------------------------------------------------------------------
1260
- # Display utilities
1261
- # ---------------------------------------------------------------------------
1262
-
1263
- def print_dual_matrices(matrices: List[np.ndarray]) -&gt; None:
1264
- &quot;&quot;&quot;Print matrices in a human-readable format.&quot;&quot;&quot;
1265
- for i, matrix in enumerate(matrices):
1266
- print(f&quot;Matrix {i + 1}:&quot;)
1267
- for row in matrix:
1268
- print([f&quot;({pair[0]},{pair[1]})&quot; for pair in row])
1269
- print()
1270
-
1271
-
1272
- # ---------------------------------------------------------------------------
1273
- # Backwards-compatibility aliases
1274
- # ---------------------------------------------------------------------------
1275
-
1276
- def generate_2x2_dual_matrices(min_val: int = 0, max_val: int = 5) -&gt; List[np.ndarray]:
1277
- &quot;&quot;&quot;Alias for generate_dual_matrices(2, 2, ...) — kept for compatibility.&quot;&quot;&quot;
1278
- return generate_dual_matrices(2, 2, min_val, max_val)
1279
-
1280
- </gradio-file><gradio-file name="src/theme.py">
1281
- &quot;&quot;&quot;
1282
- theme.py — UI theme tokens for the public Hugging Face Space.
1283
- &quot;&quot;&quot;
1284
-
1285
- BG_PAGE = &quot;#0F0F0F&quot;
1286
- BG_SURFACE = &quot;#1A1410&quot;
1287
- BG_ALT = &quot;#15110D&quot;
1288
-
1289
- BORDER_STRONG = &quot;#3D2418&quot;
1290
- BORDER_SOFT = &quot;#2A2520&quot;
1291
-
1292
- ACCENT = &quot;#E8610A&quot;
1293
- ACCENT_DIM = &quot;#8C3A06&quot;
1294
-
1295
- TEXT_PRI = &quot;#F0EDE8&quot;
1296
- TEXT_MUT = &quot;#7A7570&quot;
1297
- TEXT_DIM = &quot;#4A4540&quot;
1298
-
1299
-
1300
- GRADIO_CSS = &quot;&quot;&quot;
1301
- @import url(&#x27;https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@600;700&amp;family=IBM+Plex+Mono:wght@400;500&amp;display=swap&#x27;);
1302
-
1303
- :root {
1304
- --bg-page: #0F0F0F;
1305
- --bg-surface: #1A1410;
1306
- --bg-alt: #15110D;
1307
- --border-strong: #3D2418;
1308
- --border-soft: #2A2520;
1309
- --accent: #E8610A;
1310
- --accent-dim: #8C3A06;
1311
- --text-pri: #F0EDE8;
1312
- --text-mut: #7A7570;
1313
- --text-dim: #4A4540;
1314
- --radius: 6px;
1315
- }
1316
-
1317
- body, .gradio-container {
1318
- background-color: var(--bg-page) !important;
1319
- color: var(--text-pri) !important;
1320
- font-family: &#x27;IBM Plex Mono&#x27;, &#x27;Courier New&#x27;, monospace !important;
1321
- }
1322
-
1323
- h1, h2, h3, h4, .section-label {
1324
- font-family: &#x27;Space Grotesk&#x27;, sans-serif !important;
1325
- font-weight: 700 !important;
1326
- text-transform: uppercase !important;
1327
- letter-spacing: -0.02em !important;
1328
- color: var(--text-pri) !important;
1329
- }
1330
-
1331
- .section-num {
1332
- color: var(--accent) !important;
1333
- font-family: &#x27;Space Grotesk&#x27;, sans-serif !important;
1334
- font-weight: 700 !important;
1335
- font-size: 0.75rem !important;
1336
- text-transform: uppercase !important;
1337
- letter-spacing: 0.12em !important;
1338
- }
1339
-
1340
- .block, .panel, .gr-box, .gr-form {
1341
- background-color: var(--bg-surface) !important;
1342
- border: 1px solid var(--border-strong) !important;
1343
- border-radius: var(--radius) !important;
1344
- }
1345
-
1346
- .tabs &gt; .tab-nav {
1347
- background-color: var(--bg-alt) !important;
1348
- border-bottom: 1px solid var(--border-strong) !important;
1349
- }
1350
-
1351
- .tabs &gt; .tab-nav &gt; button {
1352
- color: var(--text-mut) !important;
1353
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1354
- font-size: 0.78rem !important;
1355
- text-transform: uppercase !important;
1356
- letter-spacing: 0.08em !important;
1357
- border-bottom: 2px solid transparent !important;
1358
- padding: 0.55rem 1.1rem !important;
1359
- background: transparent !important;
1360
- transition: color 0.15s, border-color 0.15s;
1361
- }
1362
-
1363
- .tabs &gt; .tab-nav &gt; button.selected,
1364
- .tabs &gt; .tab-nav &gt; button:hover {
1365
- color: var(--accent) !important;
1366
- border-bottom-color: var(--accent) !important;
1367
- }
1368
-
1369
- input, textarea, select, .gr-input, .gr-textbox {
1370
- background-color: var(--bg-alt) !important;
1371
- border: 1px solid var(--border-strong) !important;
1372
- color: var(--text-pri) !important;
1373
- border-radius: var(--radius) !important;
1374
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1375
- }
1376
-
1377
- input:focus, textarea:focus {
1378
- border-color: var(--accent) !important;
1379
- outline: none !important;
1380
- box-shadow: 0 0 0 2px rgba(232, 97, 10, 0.25) !important;
1381
- }
1382
-
1383
- button.primary, .gr-button-primary {
1384
- background-color: var(--accent) !important;
1385
- color: #fff !important;
1386
- border: none !important;
1387
- border-radius: var(--radius) !important;
1388
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1389
- font-weight: 500 !important;
1390
- letter-spacing: 0.04em !important;
1391
- transition: background-color 0.15s;
1392
- }
1393
-
1394
- button.primary:hover {
1395
- background-color: var(--accent-dim) !important;
1396
- }
1397
-
1398
- button.secondary, .gr-button-secondary {
1399
- background-color: transparent !important;
1400
- color: var(--text-mut) !important;
1401
- border: 1px solid var(--border-strong) !important;
1402
- border-radius: var(--radius) !important;
1403
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1404
- }
1405
-
1406
- button.secondary:hover {
1407
- border-color: var(--accent) !important;
1408
- color: var(--accent) !important;
1409
- }
1410
-
1411
- table, .gr-dataframe table {
1412
- background-color: var(--bg-surface) !important;
1413
- color: var(--text-pri) !important;
1414
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1415
- font-size: 0.82rem !important;
1416
- border-collapse: collapse !important;
1417
- }
1418
-
1419
- th {
1420
- background-color: var(--bg-alt) !important;
1421
- color: var(--accent) !important;
1422
- font-weight: 500 !important;
1423
- text-transform: uppercase !important;
1424
- letter-spacing: 0.06em !important;
1425
- padding: 6px 10px !important;
1426
- border-bottom: 1px solid var(--border-strong) !important;
1427
- }
1428
-
1429
- td {
1430
- padding: 4px 10px !important;
1431
- border-bottom: 1px solid var(--border-soft) !important;
1432
- }
1433
-
1434
- tr:nth-child(even) td {
1435
- background-color: var(--bg-alt) !important;
1436
- }
1437
-
1438
- tr:hover td {
1439
- background-color: rgba(232, 97, 10, 0.06) !important;
1440
- }
1441
-
1442
- .gr-dropdown, .gr-select {
1443
- background-color: var(--bg-alt) !important;
1444
- border: 1px solid var(--border-strong) !important;
1445
- color: var(--text-pri) !important;
1446
- border-radius: var(--radius) !important;
1447
- }
1448
-
1449
- .prose, .gr-markdown {
1450
- color: var(--text-pri) !important;
1451
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1452
- line-height: 1.65 !important;
1453
- }
1454
-
1455
- .prose strong, .gr-markdown strong {
1456
- color: var(--accent) !important;
1457
- }
1458
-
1459
- .prose code, .gr-markdown code {
1460
- background-color: var(--bg-alt) !important;
1461
- color: var(--text-pri) !important;
1462
- padding: 0.1rem 0.3rem !important;
1463
- border-radius: 4px !important;
1464
- border: 1px solid var(--border-soft) !important;
1465
- }
1466
-
1467
- label {
1468
- color: var(--text-mut) !important;
1469
- font-family: &#x27;IBM Plex Mono&#x27;, monospace !important;
1470
- font-size: 0.78rem !important;
1471
- letter-spacing: 0.04em !important;
1472
- }
1473
- &quot;&quot;&quot;
1474
-
1475
- </gradio-file>
1476
- </gradio-lite>
1477
- <script type="module" src="https://cdn.jsdelivr.net/npm/@gradio/lite/dist/lite.js"></script>
1478
  </body>
1479
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Game Theory Matrix Classifier</title>
7
+ <meta
8
+ name="description"
9
+ content="Static 2x2 game theory matrix explorer with Nash equilibrium detection and classic game classification."
10
+ >
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
14
+ <link rel="stylesheet" href="./styles.css">
 
 
 
 
 
 
15
  </head>
16
  <body>
17
+ <main class="page-shell">
18
+ <section class="hero card">
19
+ <p class="eyebrow">Public Space / Static / 2x2 only</p>
20
+ <h1>Game Theory Matrix Classifier</h1>
21
+ <p class="hero-copy">
22
+ Lightweight public demo. Enter any 2x2 payoff matrix, find pure-strategy Nash equilibria,
23
+ compute mixed equilibrium when needed, classify classic game type.
24
+ </p>
25
+ </section>
26
+
27
+ <section class="layout">
28
+ <aside class="card controls">
29
+ <div class="section-head">
30
+ <p class="eyebrow">Inputs</p>
31
+ <h2>Matrix Builder</h2>
32
+ </div>
33
+
34
+ <label class="control-label" for="preset">Load preset</label>
35
+ <select id="preset" class="control-input"></select>
36
+
37
+ <div class="grid-head">
38
+ <span>Top row</span>
39
+ <span>Bottom row</span>
40
+ </div>
41
+
42
+ <div class="input-grid">
43
+ <label class="payoff-card">
44
+ <span class="payoff-label">(0,0)</span>
45
+ <div class="payoff-pair">
46
+ <input id="r0c0_p1" class="control-input" type="number" step="1" value="3">
47
+ <input id="r0c0_p2" class="control-input" type="number" step="1" value="3">
48
+ </div>
49
+ </label>
50
+
51
+ <label class="payoff-card">
52
+ <span class="payoff-label">(0,1)</span>
53
+ <div class="payoff-pair">
54
+ <input id="r0c1_p1" class="control-input" type="number" step="1" value="0">
55
+ <input id="r0c1_p2" class="control-input" type="number" step="1" value="5">
56
+ </div>
57
+ </label>
58
+
59
+ <label class="payoff-card">
60
+ <span class="payoff-label">(1,0)</span>
61
+ <div class="payoff-pair">
62
+ <input id="r1c0_p1" class="control-input" type="number" step="1" value="5">
63
+ <input id="r1c0_p2" class="control-input" type="number" step="1" value="0">
64
+ </div>
65
+ </label>
66
+
67
+ <label class="payoff-card">
68
+ <span class="payoff-label">(1,1)</span>
69
+ <div class="payoff-pair">
70
+ <input id="r1c1_p1" class="control-input" type="number" step="1" value="1">
71
+ <input id="r1c1_p2" class="control-input" type="number" step="1" value="1">
72
+ </div>
73
+ </label>
74
+ </div>
75
+
76
+ <div class="button-row">
77
+ <button id="analyze" class="primary-button" type="button">Analyze Matrix</button>
78
+ <button id="reset" class="ghost-button" type="button">Reset</button>
79
+ </div>
80
+
81
+ <div class="mini-note">
82
+ Presets include Prisoner's Dilemma, Battle of the Sexes, Stag Hunt, Chicken, Coordination, Matching Pennies.
83
+ </div>
84
+ </aside>
85
+
86
+ <section class="results">
87
+ <div class="card">
88
+ <div class="section-head">
89
+ <p class="eyebrow">Output 01</p>
90
+ <h2>Equilibrium Explorer</h2>
91
+ </div>
92
+
93
+ <div class="matrix-wrap">
94
+ <table class="matrix-table">
95
+ <thead>
96
+ <tr>
97
+ <th>Col 0</th>
98
+ <th>Col 1</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody id="matrix-body"></tbody>
102
+ </table>
103
+ </div>
104
+
105
+ <div id="summary" class="summary-card"></div>
106
+ </div>
107
+
108
+ <div class="card">
109
+ <div class="section-head">
110
+ <p class="eyebrow">Output 02</p>
111
+ <h2>Classification</h2>
112
+ </div>
113
+
114
+ <div id="game-type-badge" class="game-type-badge"></div>
115
+ <p id="game-type-description" class="game-type-description"></p>
116
+
117
+ <div class="table-wrap">
118
+ <table class="properties-table">
119
+ <thead>
120
+ <tr>
121
+ <th>Property</th>
122
+ <th>Value</th>
123
+ <th>Note</th>
124
+ </tr>
125
+ </thead>
126
+ <tbody id="properties-body"></tbody>
127
+ </table>
128
+ </div>
129
+ </div>
130
+ </section>
131
+ </section>
132
+ </main>
133
+
134
+ <script type="module" src="./app.mjs"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </body>
136
  </html>
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
styles.css ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-page: #0f0f0f;
3
+ --bg-surface: #1a1410;
4
+ --bg-alt: #15110d;
5
+ --border-strong: #3d2418;
6
+ --border-soft: #2a2520;
7
+ --accent: #e8610a;
8
+ --accent-dim: #8c3a06;
9
+ --text-pri: #f0ede8;
10
+ --text-mut: #9a9288;
11
+ --text-dim: #62584f;
12
+ --good: #4caf82;
13
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ html,
21
+ body {
22
+ margin: 0;
23
+ min-height: 100%;
24
+ background:
25
+ radial-gradient(circle at top right, rgba(232, 97, 10, 0.16), transparent 26rem),
26
+ radial-gradient(circle at bottom left, rgba(58, 155, 213, 0.08), transparent 24rem),
27
+ linear-gradient(180deg, #0f0f0f 0%, #15100c 100%);
28
+ color: var(--text-pri);
29
+ }
30
+
31
+ body {
32
+ font-family: "IBM Plex Mono", monospace;
33
+ }
34
+
35
+ button,
36
+ input,
37
+ select {
38
+ font: inherit;
39
+ }
40
+
41
+ .page-shell {
42
+ width: min(1200px, calc(100% - 2rem));
43
+ margin: 0 auto;
44
+ padding: 1rem 0 2rem;
45
+ }
46
+
47
+ .layout {
48
+ display: grid;
49
+ gap: 1rem;
50
+ grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
51
+ }
52
+
53
+ .results {
54
+ display: grid;
55
+ gap: 1rem;
56
+ }
57
+
58
+ .card {
59
+ background: linear-gradient(180deg, rgba(26, 20, 16, 0.98), rgba(21, 17, 13, 0.98));
60
+ border: 1px solid var(--border-strong);
61
+ border-radius: 18px;
62
+ box-shadow: var(--shadow);
63
+ padding: 1.15rem;
64
+ }
65
+
66
+ .hero {
67
+ margin-bottom: 1rem;
68
+ padding: 1.4rem 1.2rem 1.3rem;
69
+ }
70
+
71
+ .hero h1,
72
+ .section-head h2 {
73
+ font-family: "Space Grotesk", sans-serif;
74
+ font-weight: 700;
75
+ letter-spacing: -0.03em;
76
+ margin: 0;
77
+ }
78
+
79
+ .hero h1 {
80
+ font-size: clamp(1.8rem, 4vw, 3rem);
81
+ text-transform: uppercase;
82
+ }
83
+
84
+ .hero-copy {
85
+ color: var(--text-mut);
86
+ line-height: 1.7;
87
+ max-width: 58rem;
88
+ margin: 0.9rem 0 0;
89
+ }
90
+
91
+ .eyebrow {
92
+ margin: 0 0 0.55rem;
93
+ color: var(--accent);
94
+ font-size: 0.74rem;
95
+ letter-spacing: 0.14em;
96
+ text-transform: uppercase;
97
+ }
98
+
99
+ .section-head {
100
+ margin-bottom: 1rem;
101
+ }
102
+
103
+ .controls {
104
+ height: fit-content;
105
+ }
106
+
107
+ .control-label,
108
+ .grid-head,
109
+ .payoff-label,
110
+ .mini-note {
111
+ color: var(--text-mut);
112
+ font-size: 0.78rem;
113
+ }
114
+
115
+ .control-input {
116
+ width: 100%;
117
+ background: var(--bg-alt);
118
+ color: var(--text-pri);
119
+ border: 1px solid var(--border-strong);
120
+ border-radius: 12px;
121
+ padding: 0.82rem 0.9rem;
122
+ }
123
+
124
+ .control-input:focus {
125
+ outline: none;
126
+ border-color: var(--accent);
127
+ box-shadow: 0 0 0 3px rgba(232, 97, 10, 0.14);
128
+ }
129
+
130
+ .grid-head {
131
+ display: flex;
132
+ justify-content: space-between;
133
+ text-transform: uppercase;
134
+ letter-spacing: 0.1em;
135
+ margin: 1rem 0 0.7rem;
136
+ }
137
+
138
+ .input-grid {
139
+ display: grid;
140
+ gap: 0.75rem;
141
+ grid-template-columns: repeat(2, minmax(0, 1fr));
142
+ }
143
+
144
+ .payoff-card {
145
+ display: grid;
146
+ gap: 0.55rem;
147
+ padding: 0.9rem;
148
+ background: rgba(255, 255, 255, 0.015);
149
+ border: 1px solid var(--border-soft);
150
+ border-radius: 14px;
151
+ }
152
+
153
+ .payoff-pair {
154
+ display: grid;
155
+ gap: 0.5rem;
156
+ grid-template-columns: repeat(2, minmax(0, 1fr));
157
+ }
158
+
159
+ .button-row {
160
+ display: flex;
161
+ gap: 0.7rem;
162
+ margin-top: 1rem;
163
+ }
164
+
165
+ .primary-button,
166
+ .ghost-button {
167
+ border-radius: 12px;
168
+ padding: 0.9rem 1rem;
169
+ cursor: pointer;
170
+ }
171
+
172
+ .primary-button {
173
+ flex: 1 1 auto;
174
+ background: var(--accent);
175
+ color: white;
176
+ border: none;
177
+ }
178
+
179
+ .primary-button:hover {
180
+ background: var(--accent-dim);
181
+ }
182
+
183
+ .ghost-button {
184
+ background: transparent;
185
+ color: var(--text-mut);
186
+ border: 1px solid var(--border-strong);
187
+ }
188
+
189
+ .ghost-button:hover {
190
+ border-color: var(--accent);
191
+ color: var(--accent);
192
+ }
193
+
194
+ .mini-note {
195
+ margin-top: 0.85rem;
196
+ line-height: 1.6;
197
+ }
198
+
199
+ .matrix-wrap {
200
+ overflow-x: auto;
201
+ }
202
+
203
+ .matrix-table,
204
+ .properties-table {
205
+ width: 100%;
206
+ border-collapse: collapse;
207
+ }
208
+
209
+ .matrix-table th,
210
+ .properties-table th {
211
+ color: var(--accent);
212
+ background: rgba(255, 255, 255, 0.015);
213
+ border-bottom: 1px solid var(--border-strong);
214
+ padding: 0.85rem;
215
+ text-align: left;
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.08em;
218
+ font-size: 0.72rem;
219
+ }
220
+
221
+ .matrix-cell,
222
+ .properties-table td {
223
+ border-bottom: 1px solid var(--border-soft);
224
+ padding: 0.9rem 0.85rem;
225
+ vertical-align: top;
226
+ }
227
+
228
+ .matrix-cell {
229
+ width: 50%;
230
+ background: rgba(255, 255, 255, 0.015);
231
+ }
232
+
233
+ .matrix-cell.is-ne {
234
+ background: rgba(232, 97, 10, 0.1);
235
+ box-shadow: inset 0 0 0 1px rgba(232, 97, 10, 0.38);
236
+ }
237
+
238
+ .cell-coord,
239
+ .cell-tag {
240
+ color: var(--text-dim);
241
+ font-size: 0.72rem;
242
+ text-transform: uppercase;
243
+ letter-spacing: 0.08em;
244
+ }
245
+
246
+ .cell-payoff {
247
+ font-family: "Space Grotesk", sans-serif;
248
+ font-size: 1.35rem;
249
+ margin: 0.3rem 0;
250
+ }
251
+
252
+ .cell-tag {
253
+ color: var(--accent);
254
+ min-height: 1rem;
255
+ }
256
+
257
+ .summary-card {
258
+ display: grid;
259
+ gap: 0.45rem;
260
+ margin-top: 1rem;
261
+ padding: 1rem;
262
+ border: 1px solid var(--border-strong);
263
+ border-radius: 14px;
264
+ background: rgba(255, 255, 255, 0.015);
265
+ }
266
+
267
+ .summary-line {
268
+ color: var(--text-mut);
269
+ line-height: 1.6;
270
+ }
271
+
272
+ .summary-line strong {
273
+ color: var(--text-pri);
274
+ }
275
+
276
+ .game-type-badge {
277
+ display: inline-flex;
278
+ align-items: center;
279
+ width: fit-content;
280
+ border: 1px solid currentColor;
281
+ border-radius: 999px;
282
+ padding: 0.5rem 0.85rem;
283
+ font-family: "Space Grotesk", sans-serif;
284
+ font-size: 1.4rem;
285
+ font-weight: 700;
286
+ letter-spacing: -0.02em;
287
+ }
288
+
289
+ .game-type-description {
290
+ color: var(--text-mut);
291
+ line-height: 1.75;
292
+ margin: 1rem 0 1.2rem;
293
+ }
294
+
295
+ .table-wrap {
296
+ overflow-x: auto;
297
+ }
298
+
299
+ .properties-table td:nth-child(2) {
300
+ color: var(--text-pri);
301
+ white-space: nowrap;
302
+ }
303
+
304
+ .properties-table td:nth-child(3) {
305
+ color: var(--text-dim);
306
+ }
307
+
308
+ .bool-yes {
309
+ color: var(--good);
310
+ font-weight: 700;
311
+ }
312
+
313
+ .bool-no {
314
+ color: var(--text-dim);
315
+ font-weight: 700;
316
+ }
317
+
318
+ @media (max-width: 900px) {
319
+ .layout {
320
+ grid-template-columns: 1fr;
321
+ }
322
+
323
+ .page-shell {
324
+ width: min(100% - 1rem, 1200px);
325
+ }
326
+ }
327
+
328
+ @media (max-width: 560px) {
329
+ .input-grid {
330
+ grid-template-columns: 1fr;
331
+ }
332
+
333
+ .button-row {
334
+ flex-direction: column;
335
+ }
336
+
337
+ .cell-payoff {
338
+ font-size: 1.1rem;
339
+ }
340
+ }