@kernel.chat/kbot 3.43.0 → 3.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/lab-health.d.ts +2 -0
- package/dist/tools/lab-health.d.ts.map +1 -0
- package/dist/tools/lab-health.js +2054 -0
- package/dist/tools/lab-health.js.map +1 -0
- package/dist/tools/lab-humanities.d.ts +2 -0
- package/dist/tools/lab-humanities.d.ts.map +1 -0
- package/dist/tools/lab-humanities.js +1993 -0
- package/dist/tools/lab-humanities.js.map +1 -0
- package/dist/tools/lab-neuro.d.ts +2 -0
- package/dist/tools/lab-neuro.d.ts.map +1 -0
- package/dist/tools/lab-neuro.js +2472 -0
- package/dist/tools/lab-neuro.js.map +1 -0
- package/dist/tools/lab-social.d.ts +2 -0
- package/dist/tools/lab-social.d.ts.map +1 -0
- package/dist/tools/lab-social.js +2557 -0
- package/dist/tools/lab-social.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2557 @@
|
|
|
1
|
+
// kbot Lab Social Sciences Tools
|
|
2
|
+
// Psychology, Sociology, Economics, and Political Science
|
|
3
|
+
// 12 tools: psychometric scales, effect sizes, social networks, game theory,
|
|
4
|
+
// econometrics, inequality, survey design, demographics, sentiment analysis,
|
|
5
|
+
// voting systems, behavioral experiment design, discourse analysis.
|
|
6
|
+
// All self-contained — no external dependencies.
|
|
7
|
+
import { registerTool } from './index.js';
|
|
8
|
+
// ─── Math Helpers ────────────────────────────────────────────────────────────
|
|
9
|
+
function mean(arr) {
|
|
10
|
+
if (arr.length === 0)
|
|
11
|
+
return 0;
|
|
12
|
+
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
|
13
|
+
}
|
|
14
|
+
function variance(arr, ddof = 1) {
|
|
15
|
+
if (arr.length <= ddof)
|
|
16
|
+
return 0;
|
|
17
|
+
const m = mean(arr);
|
|
18
|
+
return arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - ddof);
|
|
19
|
+
}
|
|
20
|
+
function stddev(arr, ddof = 1) {
|
|
21
|
+
return Math.sqrt(variance(arr, ddof));
|
|
22
|
+
}
|
|
23
|
+
function covariance(a, b, ddof = 1) {
|
|
24
|
+
const n = Math.min(a.length, b.length);
|
|
25
|
+
if (n <= ddof)
|
|
26
|
+
return 0;
|
|
27
|
+
const ma = mean(a), mb = mean(b);
|
|
28
|
+
let s = 0;
|
|
29
|
+
for (let i = 0; i < n; i++)
|
|
30
|
+
s += (a[i] - ma) * (b[i] - mb);
|
|
31
|
+
return s / (n - ddof);
|
|
32
|
+
}
|
|
33
|
+
function correlation(a, b) {
|
|
34
|
+
const sa = stddev(a), sb = stddev(b);
|
|
35
|
+
if (sa === 0 || sb === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
return covariance(a, b) / (sa * sb);
|
|
38
|
+
}
|
|
39
|
+
function sum(arr) {
|
|
40
|
+
return arr.reduce((s, v) => s + v, 0);
|
|
41
|
+
}
|
|
42
|
+
function median(arr) {
|
|
43
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
44
|
+
const mid = Math.floor(sorted.length / 2);
|
|
45
|
+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
46
|
+
}
|
|
47
|
+
function percentile(arr, p) {
|
|
48
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
49
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
50
|
+
const lo = Math.floor(idx), hi = Math.ceil(idx);
|
|
51
|
+
if (lo === hi)
|
|
52
|
+
return sorted[lo];
|
|
53
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
54
|
+
}
|
|
55
|
+
function parseNumbers(s) {
|
|
56
|
+
return s.split(',').map(x => x.trim()).filter(x => x !== '').map(Number).filter(x => !isNaN(x));
|
|
57
|
+
}
|
|
58
|
+
function fmt(n, digits = 4) {
|
|
59
|
+
return Number(n.toFixed(digits)).toString();
|
|
60
|
+
}
|
|
61
|
+
/** Normal CDF approximation (Abramowitz & Stegun) */
|
|
62
|
+
function normalCdf(z) {
|
|
63
|
+
if (z < -8)
|
|
64
|
+
return 0;
|
|
65
|
+
if (z > 8)
|
|
66
|
+
return 1;
|
|
67
|
+
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
|
|
68
|
+
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
|
|
69
|
+
const sign = z < 0 ? -1 : 1;
|
|
70
|
+
const x = Math.abs(z) / Math.SQRT2;
|
|
71
|
+
const t = 1 / (1 + p * x);
|
|
72
|
+
const erf = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
|
|
73
|
+
return 0.5 * (1 + sign * erf);
|
|
74
|
+
}
|
|
75
|
+
/** Inverse normal CDF (Beasley-Springer-Moro approximation) */
|
|
76
|
+
function normalInv(p) {
|
|
77
|
+
if (p <= 0)
|
|
78
|
+
return -Infinity;
|
|
79
|
+
if (p >= 1)
|
|
80
|
+
return Infinity;
|
|
81
|
+
if (p === 0.5)
|
|
82
|
+
return 0;
|
|
83
|
+
const a = [
|
|
84
|
+
-3.969683028665376e1, 2.209460984245205e2,
|
|
85
|
+
-2.759285104469687e2, 1.383577518672690e2,
|
|
86
|
+
-3.066479806614716e1, 2.506628277459239e0,
|
|
87
|
+
];
|
|
88
|
+
const b = [
|
|
89
|
+
-5.447609879822406e1, 1.615858368580409e2,
|
|
90
|
+
-1.556989798598866e2, 6.680131188771972e1,
|
|
91
|
+
-1.328068155288572e1,
|
|
92
|
+
];
|
|
93
|
+
const c = [
|
|
94
|
+
-7.784894002430293e-3, -3.223964580411365e-1,
|
|
95
|
+
-2.400758277161838e0, -2.549732539343734e0,
|
|
96
|
+
4.374664141464968e0, 2.938163982698783e0,
|
|
97
|
+
];
|
|
98
|
+
const d = [
|
|
99
|
+
7.784695709041462e-3, 3.224671290700398e-1,
|
|
100
|
+
2.445134137142996e0, 3.754408661907416e0,
|
|
101
|
+
];
|
|
102
|
+
const pLow = 0.02425, pHigh = 1 - pLow;
|
|
103
|
+
let q, r;
|
|
104
|
+
if (p < pLow) {
|
|
105
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
106
|
+
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
107
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
108
|
+
}
|
|
109
|
+
else if (p <= pHigh) {
|
|
110
|
+
q = p - 0.5;
|
|
111
|
+
r = q * q;
|
|
112
|
+
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
|
|
113
|
+
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
117
|
+
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
118
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Gamma function via Lanczos approximation */
|
|
122
|
+
function gammaLn(z) {
|
|
123
|
+
const g = 7;
|
|
124
|
+
const coeff = [
|
|
125
|
+
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
|
|
126
|
+
771.32342877765313, -176.61502916214059, 12.507343278686905,
|
|
127
|
+
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
|
|
128
|
+
];
|
|
129
|
+
if (z < 0.5) {
|
|
130
|
+
return Math.log(Math.PI / Math.sin(Math.PI * z)) - gammaLn(1 - z);
|
|
131
|
+
}
|
|
132
|
+
z -= 1;
|
|
133
|
+
let x = coeff[0];
|
|
134
|
+
for (let i = 1; i < g + 2; i++)
|
|
135
|
+
x += coeff[i] / (z + i);
|
|
136
|
+
const t = z + g + 0.5;
|
|
137
|
+
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
|
|
138
|
+
}
|
|
139
|
+
/** Regularized incomplete beta function I_x(a, b) via continued fraction */
|
|
140
|
+
function betaIncomplete(x, a, b) {
|
|
141
|
+
if (x <= 0)
|
|
142
|
+
return 0;
|
|
143
|
+
if (x >= 1)
|
|
144
|
+
return 1;
|
|
145
|
+
if (a <= 0 || b <= 0)
|
|
146
|
+
return 0;
|
|
147
|
+
if (x > (a + 1) / (a + b + 2)) {
|
|
148
|
+
return 1 - betaIncomplete(1 - x, b, a);
|
|
149
|
+
}
|
|
150
|
+
const lnBeta = gammaLn(a) + gammaLn(b) - gammaLn(a + b);
|
|
151
|
+
const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lnBeta) / a;
|
|
152
|
+
const maxIter = 200;
|
|
153
|
+
const eps = 1e-14;
|
|
154
|
+
let f = 1, cf = 1, d = 0;
|
|
155
|
+
for (let m = 0; m <= maxIter; m++) {
|
|
156
|
+
let numerator;
|
|
157
|
+
if (m === 0) {
|
|
158
|
+
numerator = 1;
|
|
159
|
+
}
|
|
160
|
+
else if (m % 2 === 0) {
|
|
161
|
+
const k = m / 2;
|
|
162
|
+
numerator = (k * (b - k) * x) / ((a + 2 * k - 1) * (a + 2 * k));
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const k = (m - 1) / 2;
|
|
166
|
+
numerator = -((a + k) * (a + b + k) * x) / ((a + 2 * k) * (a + 2 * k + 1));
|
|
167
|
+
}
|
|
168
|
+
d = 1 + numerator * d;
|
|
169
|
+
if (Math.abs(d) < 1e-30)
|
|
170
|
+
d = 1e-30;
|
|
171
|
+
d = 1 / d;
|
|
172
|
+
cf = 1 + numerator / cf;
|
|
173
|
+
if (Math.abs(cf) < 1e-30)
|
|
174
|
+
cf = 1e-30;
|
|
175
|
+
const delta = cf * d;
|
|
176
|
+
f *= delta;
|
|
177
|
+
if (Math.abs(delta - 1) < eps)
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
return front * (f - 1);
|
|
181
|
+
}
|
|
182
|
+
/** Student's t-distribution CDF */
|
|
183
|
+
function tCdf(t, df) {
|
|
184
|
+
const x = df / (df + t * t);
|
|
185
|
+
const p = 0.5 * betaIncomplete(x, df / 2, 0.5);
|
|
186
|
+
return t >= 0 ? 1 - p : p;
|
|
187
|
+
}
|
|
188
|
+
/** Regularized lower incomplete gamma P(a, x) — series */
|
|
189
|
+
function gammaPLower(a, x) {
|
|
190
|
+
if (x <= 0)
|
|
191
|
+
return 0;
|
|
192
|
+
let s = 1 / a, term = 1 / a;
|
|
193
|
+
for (let n = 1; n < 200; n++) {
|
|
194
|
+
term *= x / (a + n);
|
|
195
|
+
s += term;
|
|
196
|
+
if (Math.abs(term) < Math.abs(s) * 1e-14)
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
return s * Math.exp(-x + a * Math.log(x) - gammaLn(a));
|
|
200
|
+
}
|
|
201
|
+
/** Chi-square CDF */
|
|
202
|
+
function chiSquareCdf(x, k) {
|
|
203
|
+
if (x <= 0)
|
|
204
|
+
return 0;
|
|
205
|
+
return gammaPLower(k / 2, x / 2);
|
|
206
|
+
}
|
|
207
|
+
/** F-distribution CDF via incomplete beta */
|
|
208
|
+
function fCdf(x, d1, d2) {
|
|
209
|
+
if (x <= 0)
|
|
210
|
+
return 0;
|
|
211
|
+
const v = d1 * x / (d1 * x + d2);
|
|
212
|
+
return betaIncomplete(v, d1 / 2, d2 / 2);
|
|
213
|
+
}
|
|
214
|
+
/** Matrix operations (small matrices for econometrics) */
|
|
215
|
+
function matMul(A, B) {
|
|
216
|
+
const m = A.length, n = B[0].length, p = B.length;
|
|
217
|
+
const C = Array.from({ length: m }, () => new Array(n).fill(0));
|
|
218
|
+
for (let i = 0; i < m; i++)
|
|
219
|
+
for (let j = 0; j < n; j++)
|
|
220
|
+
for (let k = 0; k < p; k++)
|
|
221
|
+
C[i][j] += A[i][k] * B[k][j];
|
|
222
|
+
return C;
|
|
223
|
+
}
|
|
224
|
+
function matTranspose(A) {
|
|
225
|
+
const m = A.length, n = A[0].length;
|
|
226
|
+
const T = Array.from({ length: n }, () => new Array(m).fill(0));
|
|
227
|
+
for (let i = 0; i < m; i++)
|
|
228
|
+
for (let j = 0; j < n; j++)
|
|
229
|
+
T[j][i] = A[i][j];
|
|
230
|
+
return T;
|
|
231
|
+
}
|
|
232
|
+
/** Invert a square matrix via Gauss-Jordan elimination */
|
|
233
|
+
function matInverse(M) {
|
|
234
|
+
const n = M.length;
|
|
235
|
+
// Augmented matrix [M | I]
|
|
236
|
+
const aug = M.map((row, i) => {
|
|
237
|
+
const r = [...row];
|
|
238
|
+
for (let j = 0; j < n; j++)
|
|
239
|
+
r.push(i === j ? 1 : 0);
|
|
240
|
+
return r;
|
|
241
|
+
});
|
|
242
|
+
for (let col = 0; col < n; col++) {
|
|
243
|
+
// Partial pivoting
|
|
244
|
+
let maxRow = col;
|
|
245
|
+
for (let row = col + 1; row < n; row++) {
|
|
246
|
+
if (Math.abs(aug[row][col]) > Math.abs(aug[maxRow][col]))
|
|
247
|
+
maxRow = row;
|
|
248
|
+
}
|
|
249
|
+
[aug[col], aug[maxRow]] = [aug[maxRow], aug[col]];
|
|
250
|
+
if (Math.abs(aug[col][col]) < 1e-12)
|
|
251
|
+
return null; // Singular
|
|
252
|
+
const pivot = aug[col][col];
|
|
253
|
+
for (let j = 0; j < 2 * n; j++)
|
|
254
|
+
aug[col][j] /= pivot;
|
|
255
|
+
for (let row = 0; row < n; row++) {
|
|
256
|
+
if (row === col)
|
|
257
|
+
continue;
|
|
258
|
+
const factor = aug[row][col];
|
|
259
|
+
for (let j = 0; j < 2 * n; j++)
|
|
260
|
+
aug[row][j] -= factor * aug[col][j];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return aug.map(row => row.slice(n));
|
|
264
|
+
}
|
|
265
|
+
/** Multiply matrix by vector */
|
|
266
|
+
function matVecMul(A, v) {
|
|
267
|
+
return A.map(row => row.reduce((s, val, j) => s + val * v[j], 0));
|
|
268
|
+
}
|
|
269
|
+
/** Diagonal of a matrix */
|
|
270
|
+
function matDiag(A) {
|
|
271
|
+
return A.map((row, i) => row[i]);
|
|
272
|
+
}
|
|
273
|
+
// ─── Sentiment Lexicon (VADER-like, ~500 words) ────────────────────────────
|
|
274
|
+
const SENTIMENT_LEXICON = {
|
|
275
|
+
// Strong positive (2.0 - 3.5)
|
|
276
|
+
'excellent': 3.2, 'amazing': 3.1, 'wonderful': 3.1, 'fantastic': 3.1, 'outstanding': 3.2,
|
|
277
|
+
'superb': 3.0, 'brilliant': 3.0, 'magnificent': 3.0, 'exceptional': 3.0, 'extraordinary': 3.0,
|
|
278
|
+
'perfect': 3.0, 'love': 2.9, 'adore': 2.8, 'beautiful': 2.7, 'gorgeous': 2.7,
|
|
279
|
+
'awesome': 2.8, 'incredible': 2.8, 'terrific': 2.7, 'fabulous': 2.7, 'spectacular': 2.7,
|
|
280
|
+
'marvelous': 2.7, 'phenomenal': 2.8, 'glorious': 2.6, 'sublime': 2.6, 'delightful': 2.6,
|
|
281
|
+
'thrilling': 2.5, 'ecstatic': 2.8, 'elated': 2.6, 'overjoyed': 2.7, 'blissful': 2.6,
|
|
282
|
+
'heavenly': 2.5, 'triumphant': 2.5, 'victorious': 2.4, 'exquisite': 2.5, 'stunning': 2.5,
|
|
283
|
+
// Moderate positive (1.0 - 1.9)
|
|
284
|
+
'good': 1.9, 'great': 2.0, 'nice': 1.6, 'happy': 2.2, 'glad': 1.8,
|
|
285
|
+
'pleased': 1.8, 'enjoy': 1.8, 'enjoyed': 1.8, 'like': 1.5, 'liked': 1.5,
|
|
286
|
+
'helpful': 1.7, 'useful': 1.5, 'pleasant': 1.7, 'cheerful': 1.8, 'kind': 1.7,
|
|
287
|
+
'warm': 1.3, 'gentle': 1.3, 'friendly': 1.7, 'positive': 1.5, 'fortunate': 1.6,
|
|
288
|
+
'lucky': 1.6, 'proud': 1.8, 'confident': 1.6, 'hopeful': 1.5, 'grateful': 1.8,
|
|
289
|
+
'thankful': 1.8, 'appreciate': 1.7, 'impressive': 1.8, 'remarkable': 1.7, 'effective': 1.4,
|
|
290
|
+
'successful': 1.8, 'win': 1.7, 'winning': 1.7, 'best': 2.0, 'better': 1.3,
|
|
291
|
+
'improve': 1.2, 'improved': 1.3, 'benefit': 1.4, 'comfortable': 1.4, 'safe': 1.2,
|
|
292
|
+
'strong': 1.2, 'healthy': 1.4, 'smart': 1.5, 'clever': 1.5, 'creative': 1.4,
|
|
293
|
+
'innovative': 1.4, 'elegant': 1.5, 'charming': 1.5, 'exciting': 1.8, 'fun': 1.7,
|
|
294
|
+
'laugh': 1.6, 'laughed': 1.6, 'smile': 1.5, 'smiled': 1.5, 'joy': 2.2,
|
|
295
|
+
'joyful': 2.2, 'celebrate': 1.7, 'celebration': 1.7, 'accomplish': 1.5, 'accomplished': 1.6,
|
|
296
|
+
'achieve': 1.5, 'achieved': 1.6, 'reward': 1.5, 'rewarding': 1.6, 'satisfy': 1.5,
|
|
297
|
+
'satisfied': 1.5, 'satisfying': 1.5, 'worthy': 1.3, 'valuable': 1.4, 'recommend': 1.5,
|
|
298
|
+
'recommended': 1.5, 'trust': 1.5, 'trusted': 1.5, 'reliable': 1.4, 'smooth': 1.2,
|
|
299
|
+
'bright': 1.2, 'clean': 1.0, 'clear': 1.0, 'easy': 1.2, 'simple': 1.0,
|
|
300
|
+
'calm': 1.2, 'peaceful': 1.5, 'serene': 1.5, 'relaxed': 1.3, 'refreshing': 1.4,
|
|
301
|
+
'vibrant': 1.3, 'lively': 1.4, 'energetic': 1.3, 'enthusiastic': 1.6, 'passionate': 1.5,
|
|
302
|
+
'generous': 1.6, 'compassionate': 1.6, 'caring': 1.5, 'supportive': 1.5, 'loyal': 1.5,
|
|
303
|
+
'honest': 1.4, 'sincere': 1.4, 'genuine': 1.3, 'authentic': 1.2, 'admire': 1.6,
|
|
304
|
+
'respect': 1.4, 'respected': 1.5, 'welcome': 1.3, 'uplifting': 1.6,
|
|
305
|
+
// Mild positive (0.3 - 0.9)
|
|
306
|
+
'ok': 0.5, 'okay': 0.5, 'fine': 0.5, 'decent': 0.7, 'fair': 0.5,
|
|
307
|
+
'adequate': 0.4, 'reasonable': 0.5, 'acceptable': 0.5, 'agree': 0.6, 'agreed': 0.6,
|
|
308
|
+
'correct': 0.5, 'right': 0.3, 'interest': 0.6, 'interested': 0.7, 'interesting': 0.8,
|
|
309
|
+
'curious': 0.5, 'possible': 0.3, 'available': 0.3, 'ready': 0.4, 'willing': 0.5,
|
|
310
|
+
// Mild negative (-0.3 - -0.9)
|
|
311
|
+
'boring': -0.8, 'bored': -0.7, 'dull': -0.7, 'mediocre': -0.6, 'ordinary': -0.3,
|
|
312
|
+
'average': -0.3, 'plain': -0.3, 'lack': -0.5, 'lacking': -0.6, 'miss': -0.4,
|
|
313
|
+
'missing': -0.5, 'slow': -0.4, 'late': -0.4, 'delay': -0.5, 'delayed': -0.5,
|
|
314
|
+
'confuse': -0.5, 'confused': -0.6, 'confusing': -0.6, 'unclear': -0.5, 'doubt': -0.6,
|
|
315
|
+
'doubtful': -0.6, 'uncertain': -0.5, 'unsure': -0.4, 'awkward': -0.6, 'clumsy': -0.6,
|
|
316
|
+
'odd': -0.3, 'strange': -0.4, 'weird': -0.5, 'difficult': -0.5, 'hard': -0.3,
|
|
317
|
+
'complex': -0.3, 'complicated': -0.5, 'tough': -0.4, 'struggle': -0.6, 'problem': -0.6,
|
|
318
|
+
// Moderate negative (-1.0 - -1.9)
|
|
319
|
+
'bad': -1.9, 'poor': -1.6, 'wrong': -1.3, 'fail': -1.7, 'failed': -1.8,
|
|
320
|
+
'failure': -1.8, 'lose': -1.5, 'lost': -1.3, 'losing': -1.5, 'sad': -1.8,
|
|
321
|
+
'unhappy': -1.7, 'upset': -1.6, 'angry': -1.8, 'annoyed': -1.4, 'annoying': -1.5,
|
|
322
|
+
'frustrate': -1.5, 'frustrated': -1.6, 'frustrating': -1.6, 'disappoint': -1.6,
|
|
323
|
+
'disappointed': -1.7, 'disappointing': -1.7, 'regret': -1.5, 'regretful': -1.5,
|
|
324
|
+
'sorry': -1.0, 'worry': -1.3, 'worried': -1.4, 'anxious': -1.4, 'nervous': -1.2,
|
|
325
|
+
'fear': -1.6, 'afraid': -1.5, 'scared': -1.5, 'frighten': -1.6, 'frightened': -1.6,
|
|
326
|
+
'stress': -1.3, 'stressed': -1.4, 'stressful': -1.5, 'pain': -1.6, 'painful': -1.7,
|
|
327
|
+
'hurt': -1.5, 'suffer': -1.7, 'suffering': -1.8, 'sick': -1.3, 'ill': -1.2,
|
|
328
|
+
'weak': -1.2, 'ugly': -1.7, 'useless': -1.7, 'waste': -1.5, 'wasted': -1.6,
|
|
329
|
+
'damage': -1.5, 'damaged': -1.6, 'break': -1.0, 'broken': -1.4, 'ruin': -1.7,
|
|
330
|
+
'ruined': -1.8, 'destroy': -1.8, 'destroyed': -1.9, 'corrupt': -1.7, 'guilty': -1.5,
|
|
331
|
+
'shame': -1.6, 'ashamed': -1.6, 'embarrass': -1.4, 'embarrassed': -1.5,
|
|
332
|
+
'embarrassing': -1.5, 'reject': -1.5, 'rejected': -1.7, 'lonely': -1.5,
|
|
333
|
+
'alone': -1.0, 'abandon': -1.6, 'abandoned': -1.7, 'neglect': -1.4, 'neglected': -1.5,
|
|
334
|
+
'ignore': -1.2, 'ignored': -1.4, 'betray': -1.8, 'betrayed': -1.9, 'cheat': -1.7,
|
|
335
|
+
'lie': -1.5, 'lied': -1.7, 'dishonest': -1.6, 'unfair': -1.5, 'unjust': -1.5,
|
|
336
|
+
'cruel': -1.9, 'mean': -1.3, 'selfish': -1.5, 'greedy': -1.5, 'arrogant': -1.5,
|
|
337
|
+
'rude': -1.6, 'hostile': -1.7, 'aggressive': -1.4, 'violent': -1.8, 'threat': -1.5,
|
|
338
|
+
'threaten': -1.6, 'dangerous': -1.4, 'risk': -1.0, 'risky': -1.0,
|
|
339
|
+
// Strong negative (-2.0 - -3.5)
|
|
340
|
+
'terrible': -2.7, 'horrible': -2.7, 'awful': -2.6, 'dreadful': -2.5, 'hideous': -2.5,
|
|
341
|
+
'disgusting': -2.8, 'revolting': -2.6, 'repulsive': -2.6, 'vile': -2.7, 'loathe': -2.8,
|
|
342
|
+
'hate': -2.7, 'hatred': -2.8, 'despise': -2.7, 'detest': -2.7, 'abhor': -2.8,
|
|
343
|
+
'horrific': -2.8, 'horrifying': -2.8, 'atrocious': -2.9, 'appalling': -2.7,
|
|
344
|
+
'nightmare': -2.5, 'catastrophe': -2.8, 'catastrophic': -2.9, 'disaster': -2.6,
|
|
345
|
+
'disastrous': -2.7, 'devastate': -2.7, 'devastating': -2.8, 'miserable': -2.5,
|
|
346
|
+
'agony': -2.6, 'anguish': -2.5, 'torment': -2.6, 'torture': -2.8, 'death': -2.2,
|
|
347
|
+
'die': -2.0, 'kill': -2.5, 'murder': -2.9, 'evil': -2.7, 'wicked': -2.3,
|
|
348
|
+
'toxic': -2.3, 'poison': -2.2, 'worst': -2.8, 'worthless': -2.5, 'pathetic': -2.3,
|
|
349
|
+
'hopeless': -2.3, 'helpless': -2.1, 'desperate': -2.0, 'despair': -2.4,
|
|
350
|
+
'tragic': -2.4, 'tragedy': -2.5, 'grief': -2.3, 'mourn': -2.1, 'sob': -1.8,
|
|
351
|
+
'cry': -1.5, 'scream': -1.6, 'panic': -1.8, 'terror': -2.5, 'terrify': -2.5,
|
|
352
|
+
'terrified': -2.5, 'terrifying': -2.6, 'horrified': -2.5, 'furious': -2.4,
|
|
353
|
+
'outrage': -2.3, 'outraged': -2.4, 'outrageous': -2.3, 'rage': -2.3,
|
|
354
|
+
'abuse': -2.5, 'abusive': -2.6, 'exploit': -1.8, 'exploited': -2.0,
|
|
355
|
+
};
|
|
356
|
+
/** Degree modifiers that amplify or dampen sentiment */
|
|
357
|
+
const DEGREE_MODIFIERS = {
|
|
358
|
+
'very': 1.3, 'really': 1.3, 'extremely': 1.5, 'incredibly': 1.5,
|
|
359
|
+
'absolutely': 1.4, 'totally': 1.3, 'completely': 1.3, 'utterly': 1.4,
|
|
360
|
+
'deeply': 1.3, 'highly': 1.3, 'truly': 1.2, 'particularly': 1.2,
|
|
361
|
+
'especially': 1.3, 'remarkably': 1.3, 'exceptionally': 1.4,
|
|
362
|
+
'slightly': 0.6, 'somewhat': 0.7, 'rather': 0.8, 'fairly': 0.8,
|
|
363
|
+
'quite': 1.1, 'a little': 0.6, 'a bit': 0.6, 'sort of': 0.6,
|
|
364
|
+
'kind of': 0.6, 'barely': 0.4, 'hardly': 0.4, 'scarcely': 0.4,
|
|
365
|
+
'almost': 0.8, 'nearly': 0.8,
|
|
366
|
+
};
|
|
367
|
+
/** Negation words that flip sentiment */
|
|
368
|
+
const NEGATION_WORDS = new Set([
|
|
369
|
+
'not', "n't", 'no', 'never', 'neither', 'nor', 'none', 'nobody',
|
|
370
|
+
'nothing', 'nowhere', 'hardly', 'barely', 'scarcely', 'without',
|
|
371
|
+
"doesn't", "don't", "didn't", "isn't", "aren't", "wasn't", "weren't",
|
|
372
|
+
"hasn't", "haven't", "hadn't", "won't", "wouldn't", "couldn't",
|
|
373
|
+
"shouldn't", "mustn't", "cannot", "can't",
|
|
374
|
+
]);
|
|
375
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
376
|
+
export function registerLabSocialTools() {
|
|
377
|
+
// ── 1. Psychometric Scale ──────────────────────────────────────────────
|
|
378
|
+
registerTool({
|
|
379
|
+
name: 'psychometric_scale',
|
|
380
|
+
description: 'Score and analyze psychometric instruments. Computes scale scores, Cronbach\'s alpha reliability, item-total correlations, inter-item correlations, and factor structure hints. Supports Likert scales, Big Five, PHQ-9, GAD-7, and custom instruments. Takes raw participant x item response matrices.',
|
|
381
|
+
parameters: {
|
|
382
|
+
responses: { type: 'string', description: 'JSON array of arrays — participants x items, e.g. [[5,4,3,2],[4,5,3,1]]', required: true },
|
|
383
|
+
scale_name: { type: 'string', description: 'Scale type: likert, big5, phq9, gad7, or custom', required: true },
|
|
384
|
+
reverse_items: { type: 'string', description: 'Comma-separated 0-indexed item indices to reverse score (optional)' },
|
|
385
|
+
},
|
|
386
|
+
tier: 'free',
|
|
387
|
+
async execute(args) {
|
|
388
|
+
const data = JSON.parse(String(args.responses));
|
|
389
|
+
const scaleName = String(args.scale_name).toLowerCase().trim();
|
|
390
|
+
const reverseIndices = args.reverse_items
|
|
391
|
+
? String(args.reverse_items).split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x))
|
|
392
|
+
: [];
|
|
393
|
+
if (!Array.isArray(data) || data.length === 0 || !Array.isArray(data[0])) {
|
|
394
|
+
return '**Error**: `responses` must be a JSON array of arrays (participants x items).';
|
|
395
|
+
}
|
|
396
|
+
const nParticipants = data.length;
|
|
397
|
+
const nItems = data[0].length;
|
|
398
|
+
// Find scale range for reverse scoring
|
|
399
|
+
let minVal = Infinity, maxVal = -Infinity;
|
|
400
|
+
for (const row of data) {
|
|
401
|
+
for (const v of row) {
|
|
402
|
+
if (v < minVal)
|
|
403
|
+
minVal = v;
|
|
404
|
+
if (v > maxVal)
|
|
405
|
+
maxVal = v;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// Apply reverse scoring
|
|
409
|
+
const scored = data.map(row => row.map((v, j) => reverseIndices.includes(j) ? (minVal + maxVal - v) : v));
|
|
410
|
+
// Total scores per participant
|
|
411
|
+
const totalScores = scored.map(row => sum(row));
|
|
412
|
+
// Item means and SDs
|
|
413
|
+
const itemMeans = [];
|
|
414
|
+
const itemSDs = [];
|
|
415
|
+
for (let j = 0; j < nItems; j++) {
|
|
416
|
+
const col = scored.map(row => row[j]);
|
|
417
|
+
itemMeans.push(mean(col));
|
|
418
|
+
itemSDs.push(stddev(col));
|
|
419
|
+
}
|
|
420
|
+
// Item-total correlations (corrected: exclude item from total)
|
|
421
|
+
const itemTotalCorr = [];
|
|
422
|
+
for (let j = 0; j < nItems; j++) {
|
|
423
|
+
const itemCol = scored.map(row => row[j]);
|
|
424
|
+
const restTotals = scored.map(row => sum(row) - row[j]);
|
|
425
|
+
itemTotalCorr.push(correlation(itemCol, restTotals));
|
|
426
|
+
}
|
|
427
|
+
// Inter-item correlation matrix
|
|
428
|
+
const interItemCorr = Array.from({ length: nItems }, () => new Array(nItems).fill(0));
|
|
429
|
+
for (let i = 0; i < nItems; i++) {
|
|
430
|
+
for (let j = i; j < nItems; j++) {
|
|
431
|
+
if (i === j) {
|
|
432
|
+
interItemCorr[i][j] = 1;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
const r = correlation(scored.map(row => row[i]), scored.map(row => row[j]));
|
|
436
|
+
interItemCorr[i][j] = r;
|
|
437
|
+
interItemCorr[j][i] = r;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Average inter-item correlation
|
|
442
|
+
let sumR = 0, countR = 0;
|
|
443
|
+
for (let i = 0; i < nItems; i++) {
|
|
444
|
+
for (let j = i + 1; j < nItems; j++) {
|
|
445
|
+
sumR += interItemCorr[i][j];
|
|
446
|
+
countR++;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const avgInterItem = countR > 0 ? sumR / countR : 0;
|
|
450
|
+
// Cronbach's alpha
|
|
451
|
+
// alpha = (k / (k-1)) * (1 - sum(item_variances) / total_variance)
|
|
452
|
+
const itemVariances = Array.from({ length: nItems }, (_, j) => variance(scored.map(row => row[j])));
|
|
453
|
+
const totalVariance = variance(totalScores);
|
|
454
|
+
const alpha = nItems > 1 && totalVariance > 0
|
|
455
|
+
? (nItems / (nItems - 1)) * (1 - sum(itemVariances) / totalVariance)
|
|
456
|
+
: 0;
|
|
457
|
+
// Alpha-if-item-deleted
|
|
458
|
+
const alphaIfDeleted = [];
|
|
459
|
+
for (let j = 0; j < nItems; j++) {
|
|
460
|
+
const reducedK = nItems - 1;
|
|
461
|
+
const reducedItemVarSum = sum(itemVariances) - itemVariances[j];
|
|
462
|
+
const reducedTotals = scored.map(row => sum(row) - row[j]);
|
|
463
|
+
const reducedTotalVar = variance(reducedTotals);
|
|
464
|
+
const aIfDel = reducedK > 1 && reducedTotalVar > 0
|
|
465
|
+
? (reducedK / (reducedK - 1)) * (1 - reducedItemVarSum / reducedTotalVar)
|
|
466
|
+
: 0;
|
|
467
|
+
alphaIfDeleted.push(aIfDel);
|
|
468
|
+
}
|
|
469
|
+
// Standardized alpha (based on average inter-item correlation)
|
|
470
|
+
const stdAlpha = nItems > 1
|
|
471
|
+
? (nItems * avgInterItem) / (1 + (nItems - 1) * avgInterItem)
|
|
472
|
+
: 0;
|
|
473
|
+
// Factor structure hint: eigenvalues of correlation matrix (power iteration for top 3)
|
|
474
|
+
const eigenvalues = computeEigenvalues(interItemCorr, Math.min(3, nItems));
|
|
475
|
+
// Scale-specific interpretation
|
|
476
|
+
let interpretation = '';
|
|
477
|
+
if (scaleName === 'phq9') {
|
|
478
|
+
const avgTotal = mean(totalScores);
|
|
479
|
+
if (avgTotal <= 4)
|
|
480
|
+
interpretation = 'Minimal depression (0-4)';
|
|
481
|
+
else if (avgTotal <= 9)
|
|
482
|
+
interpretation = 'Mild depression (5-9)';
|
|
483
|
+
else if (avgTotal <= 14)
|
|
484
|
+
interpretation = 'Moderate depression (10-14)';
|
|
485
|
+
else if (avgTotal <= 19)
|
|
486
|
+
interpretation = 'Moderately severe depression (15-19)';
|
|
487
|
+
else
|
|
488
|
+
interpretation = 'Severe depression (20-27)';
|
|
489
|
+
}
|
|
490
|
+
else if (scaleName === 'gad7') {
|
|
491
|
+
const avgTotal = mean(totalScores);
|
|
492
|
+
if (avgTotal <= 4)
|
|
493
|
+
interpretation = 'Minimal anxiety (0-4)';
|
|
494
|
+
else if (avgTotal <= 9)
|
|
495
|
+
interpretation = 'Mild anxiety (5-9)';
|
|
496
|
+
else if (avgTotal <= 14)
|
|
497
|
+
interpretation = 'Moderate anxiety (10-14)';
|
|
498
|
+
else
|
|
499
|
+
interpretation = 'Severe anxiety (15-21)';
|
|
500
|
+
}
|
|
501
|
+
else if (scaleName === 'big5') {
|
|
502
|
+
interpretation = 'Big Five domains scored. For a standard 44-item BFI, items map to 5 factors: Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism.';
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
interpretation = `Custom/Likert scale with ${nItems} items, ${nParticipants} respondents.`;
|
|
506
|
+
}
|
|
507
|
+
// Reliability interpretation
|
|
508
|
+
let reliabilityLabel;
|
|
509
|
+
if (alpha >= 0.9)
|
|
510
|
+
reliabilityLabel = 'Excellent';
|
|
511
|
+
else if (alpha >= 0.8)
|
|
512
|
+
reliabilityLabel = 'Good';
|
|
513
|
+
else if (alpha >= 0.7)
|
|
514
|
+
reliabilityLabel = 'Acceptable';
|
|
515
|
+
else if (alpha >= 0.6)
|
|
516
|
+
reliabilityLabel = 'Questionable';
|
|
517
|
+
else if (alpha >= 0.5)
|
|
518
|
+
reliabilityLabel = 'Poor';
|
|
519
|
+
else
|
|
520
|
+
reliabilityLabel = 'Unacceptable';
|
|
521
|
+
const lines = [
|
|
522
|
+
`# Psychometric Analysis: ${scaleName.toUpperCase()}`,
|
|
523
|
+
'',
|
|
524
|
+
`## Summary`,
|
|
525
|
+
`- **Participants**: ${nParticipants}`,
|
|
526
|
+
`- **Items**: ${nItems}`,
|
|
527
|
+
`- **Scale range**: ${minVal} - ${maxVal}`,
|
|
528
|
+
reverseIndices.length > 0 ? `- **Reverse-scored items**: ${reverseIndices.join(', ')}` : '',
|
|
529
|
+
'',
|
|
530
|
+
`## Scale Scores`,
|
|
531
|
+
`| Statistic | Value |`,
|
|
532
|
+
`|-----------|-------|`,
|
|
533
|
+
`| Mean total | ${fmt(mean(totalScores))} |`,
|
|
534
|
+
`| SD total | ${fmt(stddev(totalScores))} |`,
|
|
535
|
+
`| Median total | ${fmt(median(totalScores))} |`,
|
|
536
|
+
`| Min total | ${Math.min(...totalScores)} |`,
|
|
537
|
+
`| Max total | ${Math.max(...totalScores)} |`,
|
|
538
|
+
'',
|
|
539
|
+
`## Reliability`,
|
|
540
|
+
`| Metric | Value | Interpretation |`,
|
|
541
|
+
`|--------|-------|----------------|`,
|
|
542
|
+
`| Cronbach's alpha | ${fmt(alpha)} | ${reliabilityLabel} |`,
|
|
543
|
+
`| Standardized alpha | ${fmt(stdAlpha)} | - |`,
|
|
544
|
+
`| Avg inter-item r | ${fmt(avgInterItem)} | ${avgInterItem >= 0.15 && avgInterItem <= 0.50 ? 'Optimal range' : avgInterItem < 0.15 ? 'Items may be too heterogeneous' : 'Items may be redundant'} |`,
|
|
545
|
+
'',
|
|
546
|
+
`## Item Analysis`,
|
|
547
|
+
`| Item | Mean | SD | Item-Total r | Alpha if Deleted |`,
|
|
548
|
+
`|------|------|----|-------------|-----------------|`,
|
|
549
|
+
...Array.from({ length: nItems }, (_, j) => `| ${j} | ${fmt(itemMeans[j])} | ${fmt(itemSDs[j])} | ${fmt(itemTotalCorr[j])} | ${fmt(alphaIfDeleted[j])} |`),
|
|
550
|
+
'',
|
|
551
|
+
];
|
|
552
|
+
// Flag problematic items
|
|
553
|
+
const problematic = [];
|
|
554
|
+
for (let j = 0; j < nItems; j++) {
|
|
555
|
+
if (itemTotalCorr[j] < 0.3)
|
|
556
|
+
problematic.push(`Item ${j}: low item-total correlation (${fmt(itemTotalCorr[j])})`);
|
|
557
|
+
if (alphaIfDeleted[j] > alpha + 0.01)
|
|
558
|
+
problematic.push(`Item ${j}: removing would increase alpha to ${fmt(alphaIfDeleted[j])}`);
|
|
559
|
+
}
|
|
560
|
+
if (problematic.length > 0) {
|
|
561
|
+
lines.push(`## Flagged Items`, ...problematic.map(p => `- ${p}`), '');
|
|
562
|
+
}
|
|
563
|
+
// Factor structure
|
|
564
|
+
if (eigenvalues.length > 0) {
|
|
565
|
+
const totalEig = sum(eigenvalues.map(e => Math.max(e, 0)));
|
|
566
|
+
lines.push(`## Factor Structure Hints (Top Eigenvalues)`, `| Factor | Eigenvalue | % Variance |`, `|--------|-----------|------------|`, ...eigenvalues.map((e, i) => `| ${i + 1} | ${fmt(e)} | ${totalEig > 0 ? fmt(100 * Math.max(e, 0) / nItems) : '0'}% |`), '', `- **Kaiser criterion**: ${eigenvalues.filter(e => e > 1).length} factor(s) with eigenvalue > 1`, '');
|
|
567
|
+
}
|
|
568
|
+
if (interpretation) {
|
|
569
|
+
lines.push(`## Interpretation`, interpretation, '');
|
|
570
|
+
}
|
|
571
|
+
return lines.filter(l => l !== undefined).join('\n');
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
// ── 2. Effect Size Calculator ──────────────────────────────────────────
|
|
575
|
+
registerTool({
|
|
576
|
+
name: 'effect_size_calc',
|
|
577
|
+
description: 'Calculate and convert between effect size measures: Cohen\'s d, Hedges\' g, Glass\'s delta, odds ratio, risk ratio, NNT, eta-squared, partial eta-squared, Cohen\'s f, phi, Cramer\'s V, point-biserial r. Provides interpretation guidelines (small/medium/large).',
|
|
578
|
+
parameters: {
|
|
579
|
+
from_type: { type: 'string', description: 'Source effect size type: d, g, glass_delta, or, rr, nnt, eta_sq, partial_eta_sq, f, phi, cramers_v, r', required: true },
|
|
580
|
+
from_value: { type: 'number', description: 'Numeric value of the source effect size', required: true },
|
|
581
|
+
to_type: { type: 'string', description: 'Target effect size type (same options as from_type, or "all" for all conversions)', required: true },
|
|
582
|
+
n1: { type: 'number', description: 'Sample size group 1 (needed for some conversions)' },
|
|
583
|
+
n2: { type: 'number', description: 'Sample size group 2 (needed for some conversions)' },
|
|
584
|
+
},
|
|
585
|
+
tier: 'free',
|
|
586
|
+
async execute(args) {
|
|
587
|
+
const fromType = String(args.from_type).toLowerCase().trim();
|
|
588
|
+
const fromValue = Number(args.from_value);
|
|
589
|
+
const toType = String(args.to_type).toLowerCase().trim();
|
|
590
|
+
const n1 = args.n1 ? Number(args.n1) : undefined;
|
|
591
|
+
const n2 = args.n2 ? Number(args.n2) : undefined;
|
|
592
|
+
const nTotal = (n1 && n2) ? n1 + n2 : undefined;
|
|
593
|
+
if (isNaN(fromValue))
|
|
594
|
+
return '**Error**: `from_value` must be a number.';
|
|
595
|
+
// Step 1: Convert everything to Cohen's d as intermediate
|
|
596
|
+
let d;
|
|
597
|
+
switch (fromType) {
|
|
598
|
+
case 'd':
|
|
599
|
+
d = fromValue;
|
|
600
|
+
break;
|
|
601
|
+
case 'g': {
|
|
602
|
+
// Hedges' g to d: d = g / J, where J = 1 - 3/(4*df - 1)
|
|
603
|
+
const df = nTotal ? nTotal - 2 : 100;
|
|
604
|
+
const J = 1 - 3 / (4 * df - 1);
|
|
605
|
+
d = fromValue / J;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
case 'glass_delta':
|
|
609
|
+
d = fromValue; // Glass's delta ~= d when SDs are similar
|
|
610
|
+
break;
|
|
611
|
+
case 'r': {
|
|
612
|
+
// Point-biserial r to d
|
|
613
|
+
d = (2 * fromValue) / Math.sqrt(1 - fromValue * fromValue);
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
case 'or': {
|
|
617
|
+
// Odds ratio to d (Hasselblad & Hedges)
|
|
618
|
+
d = Math.log(fromValue) * Math.sqrt(3) / Math.PI;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case 'eta_sq': {
|
|
622
|
+
// eta-squared to d
|
|
623
|
+
d = 2 * Math.sqrt(fromValue / (1 - fromValue));
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
case 'partial_eta_sq': {
|
|
627
|
+
// partial eta-squared to d
|
|
628
|
+
d = 2 * Math.sqrt(fromValue / (1 - fromValue));
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case 'f': {
|
|
632
|
+
// Cohen's f to d (for 2-group case)
|
|
633
|
+
d = 2 * fromValue;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case 'phi': {
|
|
637
|
+
// phi to d
|
|
638
|
+
d = (2 * fromValue) / Math.sqrt(1 - fromValue * fromValue);
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
case 'cramers_v': {
|
|
642
|
+
// Cramer's V ~ phi for 2x2 tables
|
|
643
|
+
d = (2 * fromValue) / Math.sqrt(1 - fromValue * fromValue);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
case 'rr': {
|
|
647
|
+
// Risk ratio: approximate via ln(RR) relationship
|
|
648
|
+
// RR -> OR approximation for moderate base rates: OR ~ RR (rough)
|
|
649
|
+
const lnRR = Math.log(fromValue);
|
|
650
|
+
d = lnRR * Math.sqrt(3) / Math.PI;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case 'nnt': {
|
|
654
|
+
// NNT to d: NNT = 1/(CER*(RR-1)), approximate via d
|
|
655
|
+
// d = 1/NNT * sqrt(2*pi) (Furukawa approximation)
|
|
656
|
+
d = (1 / Math.abs(fromValue)) * Math.sqrt(2 * Math.PI);
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
default:
|
|
660
|
+
return `**Error**: Unknown source type \`${fromType}\`. Use: d, g, glass_delta, r, or, rr, nnt, eta_sq, partial_eta_sq, f, phi, cramers_v`;
|
|
661
|
+
}
|
|
662
|
+
// Step 2: Convert d to all target types
|
|
663
|
+
const conversions = {};
|
|
664
|
+
// Cohen's d
|
|
665
|
+
conversions['d'] = {
|
|
666
|
+
value: d,
|
|
667
|
+
label: "Cohen's d",
|
|
668
|
+
interpretation: Math.abs(d) < 0.2 ? 'Negligible' : Math.abs(d) < 0.5 ? 'Small' : Math.abs(d) < 0.8 ? 'Medium' : 'Large',
|
|
669
|
+
};
|
|
670
|
+
// Hedges' g
|
|
671
|
+
const df = nTotal ? nTotal - 2 : 100;
|
|
672
|
+
const J = 1 - 3 / (4 * df - 1);
|
|
673
|
+
conversions['g'] = {
|
|
674
|
+
value: d * J,
|
|
675
|
+
label: "Hedges' g",
|
|
676
|
+
interpretation: Math.abs(d * J) < 0.2 ? 'Negligible' : Math.abs(d * J) < 0.5 ? 'Small' : Math.abs(d * J) < 0.8 ? 'Medium' : 'Large',
|
|
677
|
+
};
|
|
678
|
+
// Glass's delta
|
|
679
|
+
conversions['glass_delta'] = {
|
|
680
|
+
value: d,
|
|
681
|
+
label: "Glass's delta",
|
|
682
|
+
interpretation: Math.abs(d) < 0.2 ? 'Negligible' : Math.abs(d) < 0.5 ? 'Small' : Math.abs(d) < 0.8 ? 'Medium' : 'Large',
|
|
683
|
+
};
|
|
684
|
+
// Point-biserial r
|
|
685
|
+
const r = d / Math.sqrt(d * d + 4);
|
|
686
|
+
conversions['r'] = {
|
|
687
|
+
value: r,
|
|
688
|
+
label: 'Point-biserial r',
|
|
689
|
+
interpretation: Math.abs(r) < 0.1 ? 'Negligible' : Math.abs(r) < 0.3 ? 'Small' : Math.abs(r) < 0.5 ? 'Medium' : 'Large',
|
|
690
|
+
};
|
|
691
|
+
// Odds ratio
|
|
692
|
+
const orVal = Math.exp(d * Math.PI / Math.sqrt(3));
|
|
693
|
+
conversions['or'] = {
|
|
694
|
+
value: orVal,
|
|
695
|
+
label: 'Odds Ratio',
|
|
696
|
+
interpretation: orVal < 1.5 ? 'Small' : orVal < 3.5 ? 'Medium' : 'Large',
|
|
697
|
+
};
|
|
698
|
+
// Eta-squared
|
|
699
|
+
const etaSq = d * d / (d * d + 4);
|
|
700
|
+
conversions['eta_sq'] = {
|
|
701
|
+
value: etaSq,
|
|
702
|
+
label: 'Eta-squared',
|
|
703
|
+
interpretation: etaSq < 0.01 ? 'Negligible' : etaSq < 0.06 ? 'Small' : etaSq < 0.14 ? 'Medium' : 'Large',
|
|
704
|
+
};
|
|
705
|
+
// Partial eta-squared (same formula for 2-group case)
|
|
706
|
+
conversions['partial_eta_sq'] = {
|
|
707
|
+
value: etaSq,
|
|
708
|
+
label: 'Partial eta-squared',
|
|
709
|
+
interpretation: etaSq < 0.01 ? 'Negligible' : etaSq < 0.06 ? 'Small' : etaSq < 0.14 ? 'Medium' : 'Large',
|
|
710
|
+
};
|
|
711
|
+
// Cohen's f
|
|
712
|
+
const fVal = Math.abs(d) / 2;
|
|
713
|
+
conversions['f'] = {
|
|
714
|
+
value: fVal,
|
|
715
|
+
label: "Cohen's f",
|
|
716
|
+
interpretation: fVal < 0.1 ? 'Negligible' : fVal < 0.25 ? 'Small' : fVal < 0.4 ? 'Medium' : 'Large',
|
|
717
|
+
};
|
|
718
|
+
// Phi coefficient
|
|
719
|
+
const phiVal = d / Math.sqrt(d * d + 4);
|
|
720
|
+
conversions['phi'] = {
|
|
721
|
+
value: phiVal,
|
|
722
|
+
label: 'Phi coefficient',
|
|
723
|
+
interpretation: Math.abs(phiVal) < 0.1 ? 'Negligible' : Math.abs(phiVal) < 0.3 ? 'Small' : Math.abs(phiVal) < 0.5 ? 'Medium' : 'Large',
|
|
724
|
+
};
|
|
725
|
+
// Cramer's V (same as phi for 2x2)
|
|
726
|
+
conversions['cramers_v'] = {
|
|
727
|
+
value: Math.abs(phiVal),
|
|
728
|
+
label: "Cramer's V",
|
|
729
|
+
interpretation: Math.abs(phiVal) < 0.1 ? 'Negligible' : Math.abs(phiVal) < 0.3 ? 'Small' : Math.abs(phiVal) < 0.5 ? 'Medium' : 'Large',
|
|
730
|
+
};
|
|
731
|
+
// Risk Ratio (approximate)
|
|
732
|
+
const rrVal = Math.exp(d * Math.PI / Math.sqrt(3) * 0.55); // rough approximation
|
|
733
|
+
conversions['rr'] = {
|
|
734
|
+
value: rrVal,
|
|
735
|
+
label: 'Risk Ratio (approx.)',
|
|
736
|
+
interpretation: rrVal < 1.25 ? 'Small' : rrVal < 2.0 ? 'Medium' : 'Large',
|
|
737
|
+
};
|
|
738
|
+
// NNT (Furukawa approximation)
|
|
739
|
+
const nntVal = Math.abs(d) > 0.001 ? Math.sqrt(2 * Math.PI) / Math.abs(d) : Infinity;
|
|
740
|
+
conversions['nnt'] = {
|
|
741
|
+
value: nntVal,
|
|
742
|
+
label: 'Number Needed to Treat',
|
|
743
|
+
interpretation: isFinite(nntVal) ? (nntVal <= 3 ? 'Very large effect' : nntVal <= 10 ? 'Moderate effect' : 'Small effect') : 'No effect',
|
|
744
|
+
};
|
|
745
|
+
// Build output
|
|
746
|
+
const lines = [
|
|
747
|
+
`# Effect Size Conversion`,
|
|
748
|
+
'',
|
|
749
|
+
`**Input**: ${conversions[fromType]?.label || fromType} = ${fmt(fromValue)}`,
|
|
750
|
+
n1 !== undefined ? `**n1** = ${n1}, **n2** = ${n2 ?? 'N/A'}` : '',
|
|
751
|
+
'',
|
|
752
|
+
];
|
|
753
|
+
if (toType === 'all') {
|
|
754
|
+
lines.push(`## All Conversions`, '', `| Measure | Value | Interpretation |`, `|---------|-------|----------------|`, ...Object.values(conversions).map(c => `| ${c.label} | ${isFinite(c.value) ? fmt(c.value) : 'N/A'} | ${c.interpretation} |`), '');
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
const target = conversions[toType];
|
|
758
|
+
if (!target) {
|
|
759
|
+
return `**Error**: Unknown target type \`${toType}\`. Use: d, g, glass_delta, r, or, rr, nnt, eta_sq, partial_eta_sq, f, phi, cramers_v, or "all".`;
|
|
760
|
+
}
|
|
761
|
+
lines.push(`## Result`, `**${target.label}** = **${isFinite(target.value) ? fmt(target.value) : 'N/A'}** (${target.interpretation})`, '');
|
|
762
|
+
}
|
|
763
|
+
lines.push(`## Interpretation Guidelines (Cohen, 1988)`, `| Measure | Small | Medium | Large |`, `|---------|-------|--------|-------|`, `| d / g | 0.2 | 0.5 | 0.8 |`, `| r | 0.1 | 0.3 | 0.5 |`, `| eta-sq | 0.01 | 0.06 | 0.14 |`, `| f | 0.10 | 0.25 | 0.40 |`, `| OR | 1.5 | 3.5 | 9.0 |`, '', `> **Note**: Conversions between some measures involve approximations. Results are most accurate for the d/g/r family. OR/RR conversions use the Hasselblad & Hedges (1995) or Furukawa (2011) methods.`);
|
|
764
|
+
return lines.filter(l => l !== undefined).join('\n');
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
// ── 3. Social Network Analysis ─────────────────────────────────────────
|
|
768
|
+
registerTool({
|
|
769
|
+
name: 'social_network_analyze',
|
|
770
|
+
description: 'Analyze social/relational networks. Computes degree centrality, betweenness centrality, closeness centrality, eigenvector centrality, clustering coefficient, density, connected components, bridges, and community detection via label propagation.',
|
|
771
|
+
parameters: {
|
|
772
|
+
nodes: { type: 'string', description: 'JSON array of node IDs, e.g. ["Alice","Bob","Carol"]', required: true },
|
|
773
|
+
edges: { type: 'string', description: 'JSON array of {from, to, weight?} objects', required: true },
|
|
774
|
+
metrics: { type: 'string', description: 'What to compute: all, centrality, community, structure (default: all)' },
|
|
775
|
+
},
|
|
776
|
+
tier: 'free',
|
|
777
|
+
async execute(args) {
|
|
778
|
+
const nodeList = JSON.parse(String(args.nodes));
|
|
779
|
+
const edgeList = JSON.parse(String(args.edges));
|
|
780
|
+
const metricsType = (args.metrics ? String(args.metrics) : 'all').toLowerCase().trim();
|
|
781
|
+
const n = nodeList.length;
|
|
782
|
+
const nodeIndex = new Map();
|
|
783
|
+
nodeList.forEach((node, i) => nodeIndex.set(node, i));
|
|
784
|
+
// Adjacency list (undirected) with weights
|
|
785
|
+
const adj = new Map();
|
|
786
|
+
for (let i = 0; i < n; i++)
|
|
787
|
+
adj.set(i, new Map());
|
|
788
|
+
for (const edge of edgeList) {
|
|
789
|
+
const u = nodeIndex.get(edge.from);
|
|
790
|
+
const v = nodeIndex.get(edge.to);
|
|
791
|
+
if (u === undefined || v === undefined)
|
|
792
|
+
continue;
|
|
793
|
+
const w = edge.weight ?? 1;
|
|
794
|
+
adj.get(u).set(v, w);
|
|
795
|
+
adj.get(v).set(u, w);
|
|
796
|
+
}
|
|
797
|
+
const totalEdges = edgeList.length;
|
|
798
|
+
const lines = [`# Social Network Analysis`, '', `- **Nodes**: ${n}`, `- **Edges**: ${totalEdges}`, ''];
|
|
799
|
+
// ── Structure metrics ──
|
|
800
|
+
if (metricsType === 'all' || metricsType === 'structure') {
|
|
801
|
+
// Density
|
|
802
|
+
const maxEdges = n * (n - 1) / 2;
|
|
803
|
+
const density = maxEdges > 0 ? totalEdges / maxEdges : 0;
|
|
804
|
+
// Connected components (BFS)
|
|
805
|
+
const visited = new Set();
|
|
806
|
+
const components = [];
|
|
807
|
+
for (let i = 0; i < n; i++) {
|
|
808
|
+
if (visited.has(i))
|
|
809
|
+
continue;
|
|
810
|
+
const comp = [];
|
|
811
|
+
const queue = [i];
|
|
812
|
+
visited.add(i);
|
|
813
|
+
while (queue.length > 0) {
|
|
814
|
+
const cur = queue.shift();
|
|
815
|
+
comp.push(cur);
|
|
816
|
+
for (const [nb] of adj.get(cur)) {
|
|
817
|
+
if (!visited.has(nb)) {
|
|
818
|
+
visited.add(nb);
|
|
819
|
+
queue.push(nb);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
components.push(comp);
|
|
824
|
+
}
|
|
825
|
+
// Bridges (naive: remove each edge, check if components increase)
|
|
826
|
+
const bridges = [];
|
|
827
|
+
for (const edge of edgeList) {
|
|
828
|
+
const u = nodeIndex.get(edge.from);
|
|
829
|
+
const v = nodeIndex.get(edge.to);
|
|
830
|
+
// Temporarily remove edge
|
|
831
|
+
adj.get(u).delete(v);
|
|
832
|
+
adj.get(v).delete(u);
|
|
833
|
+
// BFS from u
|
|
834
|
+
const vis2 = new Set();
|
|
835
|
+
const q2 = [u];
|
|
836
|
+
vis2.add(u);
|
|
837
|
+
while (q2.length > 0) {
|
|
838
|
+
const cur = q2.shift();
|
|
839
|
+
for (const [nb] of adj.get(cur)) {
|
|
840
|
+
if (!vis2.has(nb)) {
|
|
841
|
+
vis2.add(nb);
|
|
842
|
+
q2.push(nb);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (!vis2.has(v)) {
|
|
847
|
+
bridges.push(`${edge.from} -- ${edge.to}`);
|
|
848
|
+
}
|
|
849
|
+
// Restore edge
|
|
850
|
+
const w = edge.weight ?? 1;
|
|
851
|
+
adj.get(u).set(v, w);
|
|
852
|
+
adj.get(v).set(u, w);
|
|
853
|
+
}
|
|
854
|
+
// Average clustering coefficient
|
|
855
|
+
let totalCC = 0;
|
|
856
|
+
const nodeCC = [];
|
|
857
|
+
for (let i = 0; i < n; i++) {
|
|
858
|
+
const neighbors = [...adj.get(i).keys()];
|
|
859
|
+
const ki = neighbors.length;
|
|
860
|
+
if (ki < 2) {
|
|
861
|
+
nodeCC.push(0);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
let triangles = 0;
|
|
865
|
+
for (let a = 0; a < neighbors.length; a++) {
|
|
866
|
+
for (let b = a + 1; b < neighbors.length; b++) {
|
|
867
|
+
if (adj.get(neighbors[a]).has(neighbors[b]))
|
|
868
|
+
triangles++;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const cc = (2 * triangles) / (ki * (ki - 1));
|
|
872
|
+
nodeCC.push(cc);
|
|
873
|
+
totalCC += cc;
|
|
874
|
+
}
|
|
875
|
+
const avgCC = n > 0 ? totalCC / n : 0;
|
|
876
|
+
lines.push(`## Network Structure`, `| Metric | Value |`, `|--------|-------|`, `| Density | ${fmt(density)} |`, `| Components | ${components.length} |`, `| Largest component | ${Math.max(...components.map(c => c.length))} nodes |`, `| Avg clustering coeff | ${fmt(avgCC)} |`, `| Bridges | ${bridges.length} |`, '');
|
|
877
|
+
if (bridges.length > 0) {
|
|
878
|
+
lines.push(`**Bridges**: ${bridges.join(', ')}`, '');
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// ── Centrality metrics ──
|
|
882
|
+
if (metricsType === 'all' || metricsType === 'centrality') {
|
|
883
|
+
// Degree centrality
|
|
884
|
+
const degreeCent = nodeList.map((_, i) => adj.get(i).size / (n - 1 || 1));
|
|
885
|
+
// Closeness centrality (BFS shortest paths)
|
|
886
|
+
const closenessCent = [];
|
|
887
|
+
for (let i = 0; i < n; i++) {
|
|
888
|
+
const dist = new Map();
|
|
889
|
+
dist.set(i, 0);
|
|
890
|
+
const queue = [i];
|
|
891
|
+
while (queue.length > 0) {
|
|
892
|
+
const cur = queue.shift();
|
|
893
|
+
const d = dist.get(cur);
|
|
894
|
+
for (const [nb] of adj.get(cur)) {
|
|
895
|
+
if (!dist.has(nb)) {
|
|
896
|
+
dist.set(nb, d + 1);
|
|
897
|
+
queue.push(nb);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const reachable = dist.size - 1;
|
|
902
|
+
if (reachable === 0) {
|
|
903
|
+
closenessCent.push(0);
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
let totalDist = 0;
|
|
907
|
+
for (const [node, d] of dist) {
|
|
908
|
+
if (node !== i)
|
|
909
|
+
totalDist += d;
|
|
910
|
+
}
|
|
911
|
+
// Wasserman & Faust normalization
|
|
912
|
+
closenessCent.push(reachable / ((n - 1) * (totalDist / reachable)));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Betweenness centrality (Brandes algorithm)
|
|
916
|
+
const betweennessCent = new Array(n).fill(0);
|
|
917
|
+
for (let s = 0; s < n; s++) {
|
|
918
|
+
const stack = [];
|
|
919
|
+
const pred = Array.from({ length: n }, () => []);
|
|
920
|
+
const sigma = new Array(n).fill(0);
|
|
921
|
+
sigma[s] = 1;
|
|
922
|
+
const dist = new Array(n).fill(-1);
|
|
923
|
+
dist[s] = 0;
|
|
924
|
+
const queue = [s];
|
|
925
|
+
while (queue.length > 0) {
|
|
926
|
+
const v = queue.shift();
|
|
927
|
+
stack.push(v);
|
|
928
|
+
for (const [w] of adj.get(v)) {
|
|
929
|
+
if (dist[w] < 0) {
|
|
930
|
+
queue.push(w);
|
|
931
|
+
dist[w] = dist[v] + 1;
|
|
932
|
+
}
|
|
933
|
+
if (dist[w] === dist[v] + 1) {
|
|
934
|
+
sigma[w] += sigma[v];
|
|
935
|
+
pred[w].push(v);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const delta = new Array(n).fill(0);
|
|
940
|
+
while (stack.length > 0) {
|
|
941
|
+
const w = stack.pop();
|
|
942
|
+
for (const v of pred[w]) {
|
|
943
|
+
delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w]);
|
|
944
|
+
}
|
|
945
|
+
if (w !== s)
|
|
946
|
+
betweennessCent[w] += delta[w];
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Normalize
|
|
950
|
+
const normFactor = n > 2 ? (n - 1) * (n - 2) : 1;
|
|
951
|
+
for (let i = 0; i < n; i++)
|
|
952
|
+
betweennessCent[i] /= normFactor;
|
|
953
|
+
// Eigenvector centrality (power iteration)
|
|
954
|
+
let eigenVec = new Array(n).fill(1 / Math.sqrt(n));
|
|
955
|
+
for (let iter = 0; iter < 100; iter++) {
|
|
956
|
+
const newVec = new Array(n).fill(0);
|
|
957
|
+
for (let i = 0; i < n; i++) {
|
|
958
|
+
for (const [nb, w] of adj.get(i)) {
|
|
959
|
+
newVec[i] += w * eigenVec[nb];
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const norm = Math.sqrt(newVec.reduce((s, v) => s + v * v, 0));
|
|
963
|
+
if (norm === 0)
|
|
964
|
+
break;
|
|
965
|
+
const scaled = newVec.map(v => v / norm);
|
|
966
|
+
const diff = scaled.reduce((s, v, i) => s + Math.abs(v - eigenVec[i]), 0);
|
|
967
|
+
eigenVec = scaled;
|
|
968
|
+
if (diff < 1e-10)
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
lines.push(`## Centrality Measures`, `| Node | Degree | Closeness | Betweenness | Eigenvector |`, `|------|--------|-----------|-------------|-------------|`, ...nodeList.map((name, i) => `| ${name} | ${fmt(degreeCent[i])} | ${fmt(closenessCent[i])} | ${fmt(betweennessCent[i])} | ${fmt(eigenVec[i])} |`), '', `**Most central node (by betweenness)**: ${nodeList[betweennessCent.indexOf(Math.max(...betweennessCent))]}`, `**Most central node (by eigenvector)**: ${nodeList[eigenVec.indexOf(Math.max(...eigenVec))]}`, '');
|
|
972
|
+
}
|
|
973
|
+
// ── Community detection (label propagation) ──
|
|
974
|
+
if (metricsType === 'all' || metricsType === 'community') {
|
|
975
|
+
const labels = nodeList.map((_, i) => i); // Each node starts as own community
|
|
976
|
+
const order = nodeList.map((_, i) => i);
|
|
977
|
+
for (let iter = 0; iter < 50; iter++) {
|
|
978
|
+
// Shuffle order
|
|
979
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
980
|
+
const j = Math.floor(deterministicRandom(iter * n + i) * (i + 1));
|
|
981
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
982
|
+
}
|
|
983
|
+
let changed = false;
|
|
984
|
+
for (const node of order) {
|
|
985
|
+
const labelCounts = new Map();
|
|
986
|
+
for (const [nb, w] of adj.get(node)) {
|
|
987
|
+
const lbl = labels[nb];
|
|
988
|
+
labelCounts.set(lbl, (labelCounts.get(lbl) ?? 0) + w);
|
|
989
|
+
}
|
|
990
|
+
if (labelCounts.size === 0)
|
|
991
|
+
continue;
|
|
992
|
+
let maxCount = -Infinity;
|
|
993
|
+
let bestLabel = labels[node];
|
|
994
|
+
for (const [lbl, count] of labelCounts) {
|
|
995
|
+
if (count > maxCount) {
|
|
996
|
+
maxCount = count;
|
|
997
|
+
bestLabel = lbl;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (bestLabel !== labels[node]) {
|
|
1001
|
+
labels[node] = bestLabel;
|
|
1002
|
+
changed = true;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (!changed)
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
// Group by community
|
|
1009
|
+
const communities = new Map();
|
|
1010
|
+
labels.forEach((lbl, i) => {
|
|
1011
|
+
if (!communities.has(lbl))
|
|
1012
|
+
communities.set(lbl, []);
|
|
1013
|
+
communities.get(lbl).push(nodeList[i]);
|
|
1014
|
+
});
|
|
1015
|
+
// Modularity
|
|
1016
|
+
const m2 = totalEdges * 2; // sum of degrees (each edge counted twice for undirected)
|
|
1017
|
+
let Q = 0;
|
|
1018
|
+
if (m2 > 0) {
|
|
1019
|
+
for (let i = 0; i < n; i++) {
|
|
1020
|
+
for (let j = 0; j < n; j++) {
|
|
1021
|
+
if (labels[i] !== labels[j])
|
|
1022
|
+
continue;
|
|
1023
|
+
const aij = adj.get(i).get(j) ?? 0;
|
|
1024
|
+
const ki = adj.get(i).size;
|
|
1025
|
+
const kj = adj.get(j).size;
|
|
1026
|
+
Q += aij - (ki * kj) / m2;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
Q /= m2;
|
|
1030
|
+
}
|
|
1031
|
+
let commIdx = 0;
|
|
1032
|
+
lines.push(`## Community Detection (Label Propagation)`, `- **Communities found**: ${communities.size}`, `- **Modularity (Q)**: ${fmt(Q)}`, '', `| Community | Members |`, `|-----------|---------|`, ...[...communities.values()].map(members => `| ${++commIdx} | ${members.join(', ')} |`), '');
|
|
1033
|
+
}
|
|
1034
|
+
return lines.join('\n');
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
// ── 4. Game Theory Solver ──────────────────────────────────────────────
|
|
1038
|
+
registerTool({
|
|
1039
|
+
name: 'game_theory_solve',
|
|
1040
|
+
description: 'Solve normal-form 2-player games. Finds pure and mixed strategy Nash equilibria, dominant strategies, Pareto optimal outcomes, minimax strategies. Supports custom payoff matrices and named classics (prisoner\'s dilemma, chicken, stag hunt, battle of sexes).',
|
|
1041
|
+
parameters: {
|
|
1042
|
+
payoff_matrix: { type: 'string', description: 'JSON 2D array of [row_payoff, col_payoff] tuples. E.g. [[[3,3],[0,5]],[[5,0],[1,1]]] for prisoner\'s dilemma', required: true },
|
|
1043
|
+
player_names: { type: 'string', description: 'Comma-separated player names (default: "Row,Column")' },
|
|
1044
|
+
},
|
|
1045
|
+
tier: 'free',
|
|
1046
|
+
async execute(args) {
|
|
1047
|
+
const matrix = JSON.parse(String(args.payoff_matrix));
|
|
1048
|
+
const names = args.player_names
|
|
1049
|
+
? String(args.player_names).split(',').map(s => s.trim())
|
|
1050
|
+
: ['Row', 'Column'];
|
|
1051
|
+
const nRows = matrix.length;
|
|
1052
|
+
const nCols = matrix[0].length;
|
|
1053
|
+
if (nRows === 0 || nCols === 0)
|
|
1054
|
+
return '**Error**: Payoff matrix cannot be empty.';
|
|
1055
|
+
const p1 = names[0] || 'Row';
|
|
1056
|
+
const p2 = names[1] || 'Column';
|
|
1057
|
+
const lines = [
|
|
1058
|
+
`# Game Theory Analysis`,
|
|
1059
|
+
'',
|
|
1060
|
+
`**Players**: ${p1} (rows) vs ${p2} (columns)`,
|
|
1061
|
+
`**Strategies**: ${p1} has ${nRows}, ${p2} has ${nCols}`,
|
|
1062
|
+
'',
|
|
1063
|
+
`## Payoff Matrix`,
|
|
1064
|
+
'',
|
|
1065
|
+
`| | ${Array.from({ length: nCols }, (_, j) => `${p2}-${j}`).join(' | ')} |`,
|
|
1066
|
+
`|${'-|'.repeat(nCols + 1)}`,
|
|
1067
|
+
...matrix.map((row, i) => `| **${p1}-${i}** | ${row.map(([a, b]) => `(${a}, ${b})`).join(' | ')} |`),
|
|
1068
|
+
'',
|
|
1069
|
+
];
|
|
1070
|
+
// ── Dominant strategies ──
|
|
1071
|
+
// Check if strategy i dominates strategy j for Row player
|
|
1072
|
+
function rowDominates(i, j, strict) {
|
|
1073
|
+
return matrix[i].every((_, c) => strict ? matrix[i][c][0] > matrix[j][c][0] : matrix[i][c][0] >= matrix[j][c][0]) && (strict ? true : matrix[i].some((_, c) => matrix[i][c][0] > matrix[j][c][0]));
|
|
1074
|
+
}
|
|
1075
|
+
function colDominates(i, j, strict) {
|
|
1076
|
+
return matrix.every((_, r) => strict ? matrix[r][i][1] > matrix[r][j][1] : matrix[r][i][1] >= matrix[r][j][1]) && (strict ? true : matrix.some((_, r) => matrix[r][i][1] > matrix[r][j][1]));
|
|
1077
|
+
}
|
|
1078
|
+
const rowDominated = new Array(nRows).fill(false);
|
|
1079
|
+
const colDominated = new Array(nCols).fill(false);
|
|
1080
|
+
const rowDominant = [];
|
|
1081
|
+
const colDominant = [];
|
|
1082
|
+
for (let i = 0; i < nRows; i++) {
|
|
1083
|
+
let dominatesAll = true;
|
|
1084
|
+
for (let j = 0; j < nRows; j++) {
|
|
1085
|
+
if (i === j)
|
|
1086
|
+
continue;
|
|
1087
|
+
if (rowDominates(j, i, false))
|
|
1088
|
+
rowDominated[i] = true;
|
|
1089
|
+
if (!rowDominates(i, j, false))
|
|
1090
|
+
dominatesAll = false;
|
|
1091
|
+
}
|
|
1092
|
+
if (dominatesAll && nRows > 1)
|
|
1093
|
+
rowDominant.push(i);
|
|
1094
|
+
}
|
|
1095
|
+
for (let i = 0; i < nCols; i++) {
|
|
1096
|
+
let dominatesAll = true;
|
|
1097
|
+
for (let j = 0; j < nCols; j++) {
|
|
1098
|
+
if (i === j)
|
|
1099
|
+
continue;
|
|
1100
|
+
if (colDominates(j, i, false))
|
|
1101
|
+
colDominated[i] = true;
|
|
1102
|
+
if (!colDominates(i, j, false))
|
|
1103
|
+
dominatesAll = false;
|
|
1104
|
+
}
|
|
1105
|
+
if (dominatesAll && nCols > 1)
|
|
1106
|
+
colDominant.push(i);
|
|
1107
|
+
}
|
|
1108
|
+
lines.push(`## Dominant Strategy Analysis`);
|
|
1109
|
+
if (rowDominant.length > 0) {
|
|
1110
|
+
lines.push(`- **${p1}** has dominant strategy: ${rowDominant.map(i => `Strategy ${i}`).join(', ')}`);
|
|
1111
|
+
}
|
|
1112
|
+
if (colDominant.length > 0) {
|
|
1113
|
+
lines.push(`- **${p2}** has dominant strategy: ${colDominant.map(i => `Strategy ${i}`).join(', ')}`);
|
|
1114
|
+
}
|
|
1115
|
+
if (rowDominant.length === 0 && colDominant.length === 0) {
|
|
1116
|
+
lines.push(`- No strictly dominant strategies found.`);
|
|
1117
|
+
}
|
|
1118
|
+
lines.push('');
|
|
1119
|
+
// ── Pure strategy Nash equilibria ──
|
|
1120
|
+
const pureNE = [];
|
|
1121
|
+
for (let r = 0; r < nRows; r++) {
|
|
1122
|
+
for (let c = 0; c < nCols; c++) {
|
|
1123
|
+
// Check if r is best response to c
|
|
1124
|
+
const rowPayoff = matrix[r][c][0];
|
|
1125
|
+
let rowBest = true;
|
|
1126
|
+
for (let r2 = 0; r2 < nRows; r2++) {
|
|
1127
|
+
if (matrix[r2][c][0] > rowPayoff) {
|
|
1128
|
+
rowBest = false;
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// Check if c is best response to r
|
|
1133
|
+
const colPayoff = matrix[r][c][1];
|
|
1134
|
+
let colBest = true;
|
|
1135
|
+
for (let c2 = 0; c2 < nCols; c2++) {
|
|
1136
|
+
if (matrix[r][c2][1] > colPayoff) {
|
|
1137
|
+
colBest = false;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (rowBest && colBest) {
|
|
1142
|
+
pureNE.push({ row: r, col: c, payoff: [rowPayoff, colPayoff] });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
lines.push(`## Pure Strategy Nash Equilibria`);
|
|
1147
|
+
if (pureNE.length === 0) {
|
|
1148
|
+
lines.push(`No pure strategy Nash equilibria found.`);
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
for (const ne of pureNE) {
|
|
1152
|
+
lines.push(`- **(${p1}-${ne.row}, ${p2}-${ne.col})** with payoffs (${ne.payoff[0]}, ${ne.payoff[1]})`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
lines.push('');
|
|
1156
|
+
// ── Mixed strategy Nash equilibrium (2x2 only) ──
|
|
1157
|
+
if (nRows === 2 && nCols === 2) {
|
|
1158
|
+
// Row player mixes to make Column indifferent:
|
|
1159
|
+
// p * M[0][0][1] + (1-p) * M[1][0][1] = p * M[0][1][1] + (1-p) * M[1][1][1]
|
|
1160
|
+
const a = matrix[0][0][1], b = matrix[0][1][1];
|
|
1161
|
+
const c = matrix[1][0][1], d = matrix[1][1][1];
|
|
1162
|
+
const denomP = (a - b - c + d);
|
|
1163
|
+
const e = matrix[0][0][0], f = matrix[0][1][0];
|
|
1164
|
+
const g = matrix[1][0][0], h = matrix[1][1][0];
|
|
1165
|
+
const denomQ = (e - f - g + h);
|
|
1166
|
+
lines.push(`## Mixed Strategy Nash Equilibrium`);
|
|
1167
|
+
if (Math.abs(denomP) < 1e-10 || Math.abs(denomQ) < 1e-10) {
|
|
1168
|
+
lines.push(`Cannot compute mixed strategy equilibrium (degenerate game).`);
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
const p = (d - c) / denomP; // Row player probability on Strategy 0
|
|
1172
|
+
const q = (h - f) / denomQ; // Column player probability on Strategy 0
|
|
1173
|
+
if (p >= 0 && p <= 1 && q >= 0 && q <= 1) {
|
|
1174
|
+
const rowExpected = p * (q * matrix[0][0][0] + (1 - q) * matrix[0][1][0]) +
|
|
1175
|
+
(1 - p) * (q * matrix[1][0][0] + (1 - q) * matrix[1][1][0]);
|
|
1176
|
+
const colExpected = p * (q * matrix[0][0][1] + (1 - q) * matrix[0][1][1]) +
|
|
1177
|
+
(1 - p) * (q * matrix[1][0][1] + (1 - q) * matrix[1][1][1]);
|
|
1178
|
+
lines.push(`- **${p1}** plays Strategy 0 with probability **${fmt(p)}**, Strategy 1 with **${fmt(1 - p)}**`, `- **${p2}** plays Strategy 0 with probability **${fmt(q)}**, Strategy 1 with **${fmt(1 - q)}**`, `- **Expected payoffs**: ${p1} = ${fmt(rowExpected)}, ${p2} = ${fmt(colExpected)}`);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
lines.push(`Mixed equilibrium probabilities out of [0,1] range; only pure equilibria exist.`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
lines.push('');
|
|
1185
|
+
}
|
|
1186
|
+
else if (nRows > 2 || nCols > 2) {
|
|
1187
|
+
lines.push(`## Mixed Strategy Nash Equilibrium`);
|
|
1188
|
+
lines.push(`> Mixed strategy computation for games larger than 2x2 requires support enumeration. Showing pure strategy analysis only.`);
|
|
1189
|
+
lines.push('');
|
|
1190
|
+
}
|
|
1191
|
+
// ── Pareto optimal outcomes ──
|
|
1192
|
+
const paretoOptimal = [];
|
|
1193
|
+
for (let r = 0; r < nRows; r++) {
|
|
1194
|
+
for (let c = 0; c < nCols; c++) {
|
|
1195
|
+
let dominated = false;
|
|
1196
|
+
for (let r2 = 0; r2 < nRows && !dominated; r2++) {
|
|
1197
|
+
for (let c2 = 0; c2 < nCols && !dominated; c2++) {
|
|
1198
|
+
if (r === r2 && c === c2)
|
|
1199
|
+
continue;
|
|
1200
|
+
if (matrix[r2][c2][0] >= matrix[r][c][0] && matrix[r2][c2][1] >= matrix[r][c][1] &&
|
|
1201
|
+
(matrix[r2][c2][0] > matrix[r][c][0] || matrix[r2][c2][1] > matrix[r][c][1])) {
|
|
1202
|
+
dominated = true;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (!dominated)
|
|
1207
|
+
paretoOptimal.push({ row: r, col: c });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
lines.push(`## Pareto Optimal Outcomes`);
|
|
1211
|
+
for (const po of paretoOptimal) {
|
|
1212
|
+
lines.push(`- **(${p1}-${po.row}, ${p2}-${po.col})**: (${matrix[po.row][po.col][0]}, ${matrix[po.row][po.col][1]})`);
|
|
1213
|
+
}
|
|
1214
|
+
lines.push('');
|
|
1215
|
+
// ── Minimax ──
|
|
1216
|
+
// Row player minimax: max over rows of (min over columns of row payoff)
|
|
1217
|
+
const rowMinimax = Math.max(...matrix.map(row => Math.min(...row.map(cell => cell[0]))));
|
|
1218
|
+
const colMinimax = Math.max(...Array.from({ length: nCols }, (_, c) => Math.min(...matrix.map(row => row[c][1]))));
|
|
1219
|
+
lines.push(`## Minimax Values`, `- **${p1}** minimax value: ${fmt(rowMinimax)}`, `- **${p2}** minimax value: ${fmt(colMinimax)}`, '');
|
|
1220
|
+
// ── Classify known games ──
|
|
1221
|
+
if (nRows === 2 && nCols === 2) {
|
|
1222
|
+
const [[a11, a12], [a21, a22]] = matrix.map(row => row.map(cell => cell[0]));
|
|
1223
|
+
const [[b11, b12], [b21, b22]] = matrix.map(row => row.map(cell => cell[1]));
|
|
1224
|
+
let gameType = 'Custom game';
|
|
1225
|
+
// Prisoner's dilemma: T > R > P > S (where T=defect/coop, R=coop/coop, P=defect/defect, S=coop/defect)
|
|
1226
|
+
if (a21 > a11 && a11 > a22 && a22 > a12 && b12 > b11 && b11 > b22 && b22 > b21) {
|
|
1227
|
+
gameType = "Prisoner's Dilemma";
|
|
1228
|
+
}
|
|
1229
|
+
// Battle of the Sexes pattern
|
|
1230
|
+
else if (a11 > a22 && a22 > a12 && a22 > a21 && b22 > b11 && b11 > b12 && b11 > b21) {
|
|
1231
|
+
gameType = 'Battle of the Sexes';
|
|
1232
|
+
}
|
|
1233
|
+
// Chicken / Hawk-Dove
|
|
1234
|
+
else if (a21 > a11 && a11 > a12 && a12 > a22) {
|
|
1235
|
+
gameType = 'Chicken / Hawk-Dove';
|
|
1236
|
+
}
|
|
1237
|
+
if (gameType !== 'Custom game') {
|
|
1238
|
+
lines.push(`## Game Classification`, `Detected pattern: **${gameType}**`, '');
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return lines.join('\n');
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
// ── 5. Econometrics Regression ─────────────────────────────────────────
|
|
1245
|
+
registerTool({
|
|
1246
|
+
name: 'econometrics_regression',
|
|
1247
|
+
description: 'OLS regression with full econometric diagnostics: coefficients, standard errors, t-stats, p-values, R-squared, adjusted R-squared, F-statistic, heteroscedasticity test (Breusch-Pagan), autocorrelation (Durbin-Watson), multicollinearity (VIF), specification test (Ramsey RESET). Supports regular and heteroscedasticity-robust (HC1) standard errors.',
|
|
1248
|
+
parameters: {
|
|
1249
|
+
y: { type: 'string', description: 'Comma-separated dependent variable values', required: true },
|
|
1250
|
+
x_vars: { type: 'string', description: 'JSON array of arrays, each inner array is one independent variable\'s values', required: true },
|
|
1251
|
+
variable_names: { type: 'string', description: 'Comma-separated variable names (first is y name, rest are x names)' },
|
|
1252
|
+
robust: { type: 'boolean', description: 'Use HC1 heteroscedasticity-robust standard errors (default false)' },
|
|
1253
|
+
},
|
|
1254
|
+
tier: 'free',
|
|
1255
|
+
async execute(args) {
|
|
1256
|
+
const yVals = parseNumbers(String(args.y));
|
|
1257
|
+
const xArrays = JSON.parse(String(args.x_vars));
|
|
1258
|
+
const useRobust = args.robust === true || args.robust === 'true';
|
|
1259
|
+
const varNames = args.variable_names
|
|
1260
|
+
? String(args.variable_names).split(',').map(s => s.trim())
|
|
1261
|
+
: ['Y', ...xArrays.map((_, i) => `X${i + 1}`)];
|
|
1262
|
+
const n = yVals.length;
|
|
1263
|
+
const k = xArrays.length; // number of regressors (excluding intercept)
|
|
1264
|
+
if (n === 0)
|
|
1265
|
+
return '**Error**: No observations provided.';
|
|
1266
|
+
if (k === 0)
|
|
1267
|
+
return '**Error**: No independent variables provided.';
|
|
1268
|
+
if (xArrays.some(x => x.length !== n))
|
|
1269
|
+
return '**Error**: All variables must have the same number of observations.';
|
|
1270
|
+
if (n <= k + 1)
|
|
1271
|
+
return '**Error**: Need more observations than parameters (n > k+1).';
|
|
1272
|
+
// Build X matrix with intercept column
|
|
1273
|
+
const X = yVals.map((_, i) => [1, ...xArrays.map(x => x[i])]);
|
|
1274
|
+
const p = k + 1; // parameters including intercept
|
|
1275
|
+
// OLS: beta = (X'X)^-1 X'y
|
|
1276
|
+
const Xt = matTranspose(X);
|
|
1277
|
+
const XtX = matMul(Xt, X);
|
|
1278
|
+
const XtXinv = matInverse(XtX);
|
|
1279
|
+
if (!XtXinv)
|
|
1280
|
+
return '**Error**: X\'X is singular. Check for perfect multicollinearity.';
|
|
1281
|
+
const Xty = matVecMul(Xt, yVals);
|
|
1282
|
+
const beta = matVecMul(XtXinv, Xty);
|
|
1283
|
+
// Predicted values and residuals
|
|
1284
|
+
const yHat = matVecMul(X, beta);
|
|
1285
|
+
const residuals = yVals.map((y, i) => y - yHat[i]);
|
|
1286
|
+
const SSR = residuals.reduce((s, r) => s + r * r, 0);
|
|
1287
|
+
const yMean = mean(yVals);
|
|
1288
|
+
const SST = yVals.reduce((s, y) => s + (y - yMean) ** 2, 0);
|
|
1289
|
+
const SSE = SST - SSR;
|
|
1290
|
+
const rSquared = SST > 0 ? 1 - SSR / SST : 0;
|
|
1291
|
+
const adjRSquared = 1 - (1 - rSquared) * (n - 1) / (n - p);
|
|
1292
|
+
const sigma2 = SSR / (n - p);
|
|
1293
|
+
// Standard errors
|
|
1294
|
+
let se;
|
|
1295
|
+
let seLbl;
|
|
1296
|
+
if (useRobust) {
|
|
1297
|
+
// HC1 robust standard errors
|
|
1298
|
+
// V_HC1 = (n/(n-p)) * (X'X)^-1 * X' * diag(e^2) * X * (X'X)^-1
|
|
1299
|
+
const diagE2 = residuals.map(r => r * r);
|
|
1300
|
+
const XtDiagE2 = Array.from({ length: p }, () => new Array(n).fill(0));
|
|
1301
|
+
for (let i = 0; i < p; i++) {
|
|
1302
|
+
for (let j = 0; j < n; j++) {
|
|
1303
|
+
XtDiagE2[i][j] = Xt[i][j] * diagE2[j];
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const meat = matMul(XtDiagE2.map(r => [r]).flat().length ? // ensure 2D
|
|
1307
|
+
XtDiagE2 : XtDiagE2, X);
|
|
1308
|
+
const robustV = matMul(matMul(XtXinv, meat), XtXinv);
|
|
1309
|
+
const hc1Factor = n / (n - p);
|
|
1310
|
+
se = matDiag(robustV).map(v => Math.sqrt(Math.abs(v) * hc1Factor));
|
|
1311
|
+
seLbl = 'HC1 Robust';
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
// Standard OLS standard errors
|
|
1315
|
+
se = matDiag(XtXinv).map(v => Math.sqrt(Math.abs(v) * sigma2));
|
|
1316
|
+
seLbl = 'OLS';
|
|
1317
|
+
}
|
|
1318
|
+
// t-statistics and p-values
|
|
1319
|
+
const tStats = beta.map((b, i) => se[i] > 0 ? b / se[i] : 0);
|
|
1320
|
+
const pValues = tStats.map(t => {
|
|
1321
|
+
const dfRes = n - p;
|
|
1322
|
+
const prob = 2 * (1 - tCdf(Math.abs(t), dfRes));
|
|
1323
|
+
return prob;
|
|
1324
|
+
});
|
|
1325
|
+
// F-statistic
|
|
1326
|
+
const fStat = (p > 1 && sigma2 > 0) ? (SSE / (p - 1)) / sigma2 : 0;
|
|
1327
|
+
const fPValue = fStat > 0 ? 1 - fCdf(fStat, p - 1, n - p) : 1;
|
|
1328
|
+
const lines = [
|
|
1329
|
+
`# OLS Regression Results`,
|
|
1330
|
+
'',
|
|
1331
|
+
`| Statistic | Value |`,
|
|
1332
|
+
`|-----------|-------|`,
|
|
1333
|
+
`| Observations | ${n} |`,
|
|
1334
|
+
`| R-squared | ${fmt(rSquared)} |`,
|
|
1335
|
+
`| Adj R-squared | ${fmt(adjRSquared)} |`,
|
|
1336
|
+
`| F-statistic | ${fmt(fStat)} (p = ${fmt(fPValue)}) |`,
|
|
1337
|
+
`| Residual SE | ${fmt(Math.sqrt(sigma2))} |`,
|
|
1338
|
+
`| SE type | ${seLbl} |`,
|
|
1339
|
+
'',
|
|
1340
|
+
`## Coefficients`,
|
|
1341
|
+
`| Variable | Coeff | Std Err | t-stat | p-value | Sig |`,
|
|
1342
|
+
`|----------|-------|---------|--------|---------|-----|`,
|
|
1343
|
+
`| (Intercept) | ${fmt(beta[0])} | ${fmt(se[0])} | ${fmt(tStats[0])} | ${fmt(pValues[0])} | ${sigStars(pValues[0])} |`,
|
|
1344
|
+
...xArrays.map((_, i) => {
|
|
1345
|
+
const idx = i + 1;
|
|
1346
|
+
const name = varNames[idx] || `X${i + 1}`;
|
|
1347
|
+
return `| ${name} | ${fmt(beta[idx])} | ${fmt(se[idx])} | ${fmt(tStats[idx])} | ${fmt(pValues[idx])} | ${sigStars(pValues[idx])} |`;
|
|
1348
|
+
}),
|
|
1349
|
+
'',
|
|
1350
|
+
`> Significance: \\*\\*\\* p<0.001, \\*\\* p<0.01, \\* p<0.05, . p<0.1`,
|
|
1351
|
+
'',
|
|
1352
|
+
];
|
|
1353
|
+
// ── Diagnostics ──
|
|
1354
|
+
lines.push(`## Diagnostics`, '');
|
|
1355
|
+
// Durbin-Watson statistic
|
|
1356
|
+
let dwNum = 0;
|
|
1357
|
+
for (let i = 1; i < n; i++)
|
|
1358
|
+
dwNum += (residuals[i] - residuals[i - 1]) ** 2;
|
|
1359
|
+
const dw = SSR > 0 ? dwNum / SSR : 0;
|
|
1360
|
+
let dwInterp = '';
|
|
1361
|
+
if (dw < 1.5)
|
|
1362
|
+
dwInterp = 'Positive autocorrelation likely';
|
|
1363
|
+
else if (dw > 2.5)
|
|
1364
|
+
dwInterp = 'Negative autocorrelation likely';
|
|
1365
|
+
else
|
|
1366
|
+
dwInterp = 'No significant autocorrelation';
|
|
1367
|
+
lines.push(`### Durbin-Watson (Autocorrelation)`, `- DW = ${fmt(dw)} (${dwInterp})`, `- Range: 0 (perfect positive) to 4 (perfect negative), 2 = no autocorrelation`, '');
|
|
1368
|
+
// Breusch-Pagan test for heteroscedasticity
|
|
1369
|
+
// Regress e^2 on X, test R^2 * n ~ chi-sq(k)
|
|
1370
|
+
const eSq = residuals.map(r => r * r);
|
|
1371
|
+
const eSqMean = mean(eSq);
|
|
1372
|
+
const eSqHat = matVecMul(X, matVecMul(matInverse(matMul(matTranspose(X), X)), matVecMul(matTranspose(X), eSq)));
|
|
1373
|
+
const SSR_bp = eSq.reduce((s, e, i) => s + (e - eSqHat[i]) ** 2, 0);
|
|
1374
|
+
const SST_bp = eSq.reduce((s, e) => s + (e - eSqMean) ** 2, 0);
|
|
1375
|
+
const R2_bp = SST_bp > 0 ? 1 - SSR_bp / SST_bp : 0;
|
|
1376
|
+
const bpStat = n * R2_bp;
|
|
1377
|
+
const bpPValue = 1 - chiSquareCdf(bpStat, k);
|
|
1378
|
+
lines.push(`### Breusch-Pagan (Heteroscedasticity)`, `- BP = ${fmt(bpStat)}, df = ${k}, p = ${fmt(bpPValue)}`, `- ${bpPValue < 0.05 ? '**Heteroscedasticity detected** (p < 0.05). Consider robust SEs.' : 'No significant heteroscedasticity (p >= 0.05).'}`, '');
|
|
1379
|
+
// VIF (Variance Inflation Factor) for each X variable
|
|
1380
|
+
if (k >= 2) {
|
|
1381
|
+
lines.push(`### VIF (Multicollinearity)`);
|
|
1382
|
+
const vifs = [];
|
|
1383
|
+
for (let j = 0; j < k; j++) {
|
|
1384
|
+
// Regress x_j on all other x variables
|
|
1385
|
+
const yj = xArrays[j];
|
|
1386
|
+
const otherX = yj.map((_, i) => [1, ...xArrays.filter((_, idx) => idx !== j).map(x => x[i])]);
|
|
1387
|
+
const otherXt = matTranspose(otherX);
|
|
1388
|
+
const otherXtX = matMul(otherXt, otherX);
|
|
1389
|
+
const otherXtXinv = matInverse(otherXtX);
|
|
1390
|
+
if (!otherXtXinv) {
|
|
1391
|
+
vifs.push({ name: varNames[j + 1] || `X${j + 1}`, vif: Infinity });
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const betaJ = matVecMul(otherXtXinv, matVecMul(otherXt, yj));
|
|
1395
|
+
const yjHat = matVecMul(otherX, betaJ);
|
|
1396
|
+
const ssrJ = yj.reduce((s, y, i) => s + (y - yjHat[i]) ** 2, 0);
|
|
1397
|
+
const sstJ = yj.reduce((s, y) => s + (y - mean(yj)) ** 2, 0);
|
|
1398
|
+
const r2j = sstJ > 0 ? 1 - ssrJ / sstJ : 0;
|
|
1399
|
+
const vifJ = 1 / (1 - r2j);
|
|
1400
|
+
vifs.push({ name: varNames[j + 1] || `X${j + 1}`, vif: vifJ });
|
|
1401
|
+
}
|
|
1402
|
+
lines.push(`| Variable | VIF | Concern |`, `|----------|-----|---------|`, ...vifs.map(v => `| ${v.name} | ${isFinite(v.vif) ? fmt(v.vif) : 'Inf'} | ${v.vif > 10 ? 'Severe multicollinearity' : v.vif > 5 ? 'Moderate concern' : 'OK'} |`), '');
|
|
1403
|
+
}
|
|
1404
|
+
// Ramsey RESET test (add y_hat^2 and y_hat^3, test their joint significance)
|
|
1405
|
+
if (n > p + 2) {
|
|
1406
|
+
const yHat2 = yHat.map(y => y * y);
|
|
1407
|
+
const yHat3 = yHat.map(y => y * y * y);
|
|
1408
|
+
const XAug = X.map((row, i) => [...row, yHat2[i], yHat3[i]]);
|
|
1409
|
+
const XAugt = matTranspose(XAug);
|
|
1410
|
+
const XAugXtXinv = matInverse(matMul(XAugt, XAug));
|
|
1411
|
+
if (XAugXtXinv) {
|
|
1412
|
+
const betaAug = matVecMul(XAugXtXinv, matVecMul(XAugt, yVals));
|
|
1413
|
+
const yHatAug = matVecMul(XAug, betaAug);
|
|
1414
|
+
const ssrAug = yVals.reduce((s, y, i) => s + (y - yHatAug[i]) ** 2, 0);
|
|
1415
|
+
const df1 = 2; // added 2 terms
|
|
1416
|
+
const df2 = n - p - 2;
|
|
1417
|
+
const resetF = df2 > 0 ? ((SSR - ssrAug) / df1) / (ssrAug / df2) : 0;
|
|
1418
|
+
const resetP = resetF > 0 ? 1 - fCdf(resetF, df1, df2) : 1;
|
|
1419
|
+
lines.push(`### Ramsey RESET (Specification)`, `- F = ${fmt(resetF)}, df1 = ${df1}, df2 = ${df2}, p = ${fmt(resetP)}`, `- ${resetP < 0.05 ? '**Possible misspecification** (p < 0.05). Consider non-linear terms or omitted variables.' : 'No significant misspecification detected (p >= 0.05).'}`, '');
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return lines.join('\n');
|
|
1423
|
+
},
|
|
1424
|
+
});
|
|
1425
|
+
// ── 6. Inequality Metrics ──────────────────────────────────────────────
|
|
1426
|
+
registerTool({
|
|
1427
|
+
name: 'inequality_metrics',
|
|
1428
|
+
description: 'Calculate inequality and distribution measures: Gini coefficient, Lorenz curve data, Theil index (GE(1)), Atkinson index, Palma ratio (top 10% / bottom 40%), top/bottom decile shares, poverty headcount ratio, and poverty gap.',
|
|
1429
|
+
parameters: {
|
|
1430
|
+
incomes: { type: 'string', description: 'Comma-separated income/wealth values', required: true },
|
|
1431
|
+
poverty_line: { type: 'number', description: 'Poverty line threshold (optional, for poverty measures)' },
|
|
1432
|
+
},
|
|
1433
|
+
tier: 'free',
|
|
1434
|
+
async execute(args) {
|
|
1435
|
+
const incomes = parseNumbers(String(args.incomes));
|
|
1436
|
+
const povertyLine = args.poverty_line !== undefined ? Number(args.poverty_line) : undefined;
|
|
1437
|
+
if (incomes.length === 0)
|
|
1438
|
+
return '**Error**: No income values provided.';
|
|
1439
|
+
const sorted = [...incomes].sort((a, b) => a - b);
|
|
1440
|
+
const n = sorted.length;
|
|
1441
|
+
const total = sum(sorted);
|
|
1442
|
+
const mu = total / n;
|
|
1443
|
+
// ── Gini coefficient ──
|
|
1444
|
+
// Gini = (2 * sum(i * x_i) - (n+1) * sum(x_i)) / (n * sum(x_i))
|
|
1445
|
+
let giniNum = 0;
|
|
1446
|
+
for (let i = 0; i < n; i++) {
|
|
1447
|
+
giniNum += (2 * (i + 1) - n - 1) * sorted[i];
|
|
1448
|
+
}
|
|
1449
|
+
const gini = total > 0 ? giniNum / (n * total) : 0;
|
|
1450
|
+
// ── Lorenz curve data ──
|
|
1451
|
+
const lorenz = [{ pop_pct: 0, income_pct: 0 }];
|
|
1452
|
+
let cumIncome = 0;
|
|
1453
|
+
const decileSize = Math.floor(n / 10);
|
|
1454
|
+
for (let i = 0; i < n; i++) {
|
|
1455
|
+
cumIncome += sorted[i];
|
|
1456
|
+
if ((i + 1) % Math.max(1, Math.floor(n / 10)) === 0 || i === n - 1) {
|
|
1457
|
+
lorenz.push({
|
|
1458
|
+
pop_pct: ((i + 1) / n) * 100,
|
|
1459
|
+
income_pct: (cumIncome / total) * 100,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
// ── Theil index (GE(1)) ──
|
|
1464
|
+
// T = (1/n) * sum((x_i / mu) * ln(x_i / mu))
|
|
1465
|
+
let theil = 0;
|
|
1466
|
+
for (const x of sorted) {
|
|
1467
|
+
if (x > 0 && mu > 0) {
|
|
1468
|
+
theil += (x / mu) * Math.log(x / mu);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
theil /= n;
|
|
1472
|
+
// ── Atkinson index (epsilon = 0.5 and 1) ──
|
|
1473
|
+
// A(e) = 1 - (1/mu) * [(1/n) * sum(x_i^(1-e))]^(1/(1-e))
|
|
1474
|
+
// For e=1: A(1) = 1 - (prod(x_i))^(1/n) / mu
|
|
1475
|
+
const positiveOnly = sorted.filter(x => x > 0);
|
|
1476
|
+
let atkinson05 = 0;
|
|
1477
|
+
let atkinson1 = 0;
|
|
1478
|
+
if (positiveOnly.length > 0 && mu > 0) {
|
|
1479
|
+
const sumPow = positiveOnly.reduce((s, x) => s + Math.sqrt(x), 0) / positiveOnly.length;
|
|
1480
|
+
atkinson05 = 1 - (sumPow * sumPow) / mu;
|
|
1481
|
+
const logSum = positiveOnly.reduce((s, x) => s + Math.log(x), 0) / positiveOnly.length;
|
|
1482
|
+
const geoMean = Math.exp(logSum);
|
|
1483
|
+
atkinson1 = 1 - geoMean / mu;
|
|
1484
|
+
}
|
|
1485
|
+
// ── Decile shares ──
|
|
1486
|
+
const decileShares = [];
|
|
1487
|
+
for (let d = 0; d < 10; d++) {
|
|
1488
|
+
const start = Math.floor(d * n / 10);
|
|
1489
|
+
const end = Math.floor((d + 1) * n / 10);
|
|
1490
|
+
const decileSum = sum(sorted.slice(start, end));
|
|
1491
|
+
decileShares.push(total > 0 ? (decileSum / total) * 100 : 0);
|
|
1492
|
+
}
|
|
1493
|
+
// Palma ratio: top 10% / bottom 40%
|
|
1494
|
+
const bottom40 = sum(sorted.slice(0, Math.floor(n * 0.4)));
|
|
1495
|
+
const top10 = sum(sorted.slice(Math.floor(n * 0.9)));
|
|
1496
|
+
const palmaRatio = bottom40 > 0 ? top10 / bottom40 : Infinity;
|
|
1497
|
+
// 90/10 ratio
|
|
1498
|
+
const p90 = percentile(sorted, 90);
|
|
1499
|
+
const p10 = percentile(sorted, 10);
|
|
1500
|
+
const ratio9010 = p10 > 0 ? p90 / p10 : Infinity;
|
|
1501
|
+
const lines = [
|
|
1502
|
+
`# Inequality Analysis`,
|
|
1503
|
+
'',
|
|
1504
|
+
`## Summary Statistics`,
|
|
1505
|
+
`| Statistic | Value |`,
|
|
1506
|
+
`|-----------|-------|`,
|
|
1507
|
+
`| N | ${n} |`,
|
|
1508
|
+
`| Mean | ${fmt(mu, 2)} |`,
|
|
1509
|
+
`| Median | ${fmt(median(sorted), 2)} |`,
|
|
1510
|
+
`| Min | ${fmt(sorted[0], 2)} |`,
|
|
1511
|
+
`| Max | ${fmt(sorted[n - 1], 2)} |`,
|
|
1512
|
+
`| Std Dev | ${fmt(stddev(sorted), 2)} |`,
|
|
1513
|
+
'',
|
|
1514
|
+
`## Inequality Measures`,
|
|
1515
|
+
`| Measure | Value | Interpretation |`,
|
|
1516
|
+
`|---------|-------|----------------|`,
|
|
1517
|
+
`| Gini coefficient | ${fmt(gini)} | ${gini < 0.3 ? 'Low inequality' : gini < 0.4 ? 'Moderate inequality' : gini < 0.5 ? 'High inequality' : 'Very high inequality'} |`,
|
|
1518
|
+
`| Theil index (GE1) | ${fmt(theil)} | ${theil < 0.2 ? 'Low' : theil < 0.5 ? 'Moderate' : 'High'} inequality |`,
|
|
1519
|
+
`| Atkinson (e=0.5) | ${fmt(atkinson05)} | ${fmt(atkinson05 * 100, 1)}% welfare loss from inequality |`,
|
|
1520
|
+
`| Atkinson (e=1.0) | ${fmt(atkinson1)} | ${fmt(atkinson1 * 100, 1)}% welfare loss from inequality |`,
|
|
1521
|
+
`| Palma ratio | ${isFinite(palmaRatio) ? fmt(palmaRatio) : 'N/A'} | Top 10% / Bottom 40% income share |`,
|
|
1522
|
+
`| 90/10 ratio | ${isFinite(ratio9010) ? fmt(ratio9010) : 'N/A'} | 90th / 10th percentile |`,
|
|
1523
|
+
'',
|
|
1524
|
+
`## Decile Income Shares`,
|
|
1525
|
+
`| Decile | Share (%) | Cumulative (%) |`,
|
|
1526
|
+
`|--------|----------|----------------|`,
|
|
1527
|
+
];
|
|
1528
|
+
let cumShare = 0;
|
|
1529
|
+
for (let d = 0; d < 10; d++) {
|
|
1530
|
+
cumShare += decileShares[d];
|
|
1531
|
+
const label = d === 0 ? 'Bottom 10%' : d === 9 ? 'Top 10%' : `${d * 10 + 1}-${(d + 1) * 10}%`;
|
|
1532
|
+
lines.push(`| ${label} | ${fmt(decileShares[d], 1)} | ${fmt(cumShare, 1)} |`);
|
|
1533
|
+
}
|
|
1534
|
+
lines.push('');
|
|
1535
|
+
// Lorenz curve data
|
|
1536
|
+
lines.push(`## Lorenz Curve Data`, `| Population % | Income % |`, `|-------------|----------|`, ...lorenz.map(p => `| ${fmt(p.pop_pct, 1)} | ${fmt(p.income_pct, 1)} |`), '');
|
|
1537
|
+
// Poverty measures
|
|
1538
|
+
if (povertyLine !== undefined) {
|
|
1539
|
+
const poor = sorted.filter(x => x < povertyLine);
|
|
1540
|
+
const headcountRatio = poor.length / n;
|
|
1541
|
+
const povertyGap = poor.length > 0
|
|
1542
|
+
? poor.reduce((s, x) => s + (povertyLine - x) / povertyLine, 0) / n
|
|
1543
|
+
: 0;
|
|
1544
|
+
const povertyGapSq = poor.length > 0
|
|
1545
|
+
? poor.reduce((s, x) => s + ((povertyLine - x) / povertyLine) ** 2, 0) / n
|
|
1546
|
+
: 0;
|
|
1547
|
+
lines.push(`## Poverty Measures (line = ${povertyLine})`, `| Measure | Value |`, `|---------|-------|`, `| Headcount ratio (FGT0) | ${fmt(headcountRatio)} (${poor.length} of ${n}) |`, `| Poverty gap (FGT1) | ${fmt(povertyGap)} |`, `| Squared poverty gap (FGT2) | ${fmt(povertyGapSq)} |`, '');
|
|
1548
|
+
}
|
|
1549
|
+
return lines.join('\n');
|
|
1550
|
+
},
|
|
1551
|
+
});
|
|
1552
|
+
// ── 7. Survey Design ───────────────────────────────────────────────────
|
|
1553
|
+
registerTool({
|
|
1554
|
+
name: 'survey_design',
|
|
1555
|
+
description: 'Generate survey methodology: sample size calculations for proportions and means, margin of error, design effect for cluster sampling, stratification guidance, and response rate adjustment. Provides formulas and recommendations.',
|
|
1556
|
+
parameters: {
|
|
1557
|
+
population_size: { type: 'number', description: 'Total population size', required: true },
|
|
1558
|
+
confidence_level: { type: 'number', description: 'Confidence level (e.g. 0.95 for 95%). Default 0.95' },
|
|
1559
|
+
margin_of_error: { type: 'number', description: 'Desired margin of error (e.g. 0.05 for 5%). Default 0.05' },
|
|
1560
|
+
expected_proportion: { type: 'number', description: 'Expected proportion for key variable (default 0.5 for maximum variance)' },
|
|
1561
|
+
design_type: { type: 'string', description: 'Sampling design: simple_random, stratified, cluster (default: simple_random)' },
|
|
1562
|
+
},
|
|
1563
|
+
tier: 'free',
|
|
1564
|
+
async execute(args) {
|
|
1565
|
+
const N = Number(args.population_size);
|
|
1566
|
+
const confLevel = args.confidence_level !== undefined ? Number(args.confidence_level) : 0.95;
|
|
1567
|
+
const moe = args.margin_of_error !== undefined ? Number(args.margin_of_error) : 0.05;
|
|
1568
|
+
const p = args.expected_proportion !== undefined ? Number(args.expected_proportion) : 0.5;
|
|
1569
|
+
const designType = (args.design_type ? String(args.design_type) : 'simple_random').toLowerCase().trim();
|
|
1570
|
+
if (N <= 0)
|
|
1571
|
+
return '**Error**: Population size must be positive.';
|
|
1572
|
+
if (confLevel <= 0 || confLevel >= 1)
|
|
1573
|
+
return '**Error**: Confidence level must be between 0 and 1.';
|
|
1574
|
+
if (moe <= 0 || moe >= 1)
|
|
1575
|
+
return '**Error**: Margin of error must be between 0 and 1.';
|
|
1576
|
+
// Z-score for confidence level
|
|
1577
|
+
const alpha = 1 - confLevel;
|
|
1578
|
+
const z = normalInv(1 - alpha / 2);
|
|
1579
|
+
// ── Sample size for proportion (infinite population) ──
|
|
1580
|
+
const n0_prop = (z * z * p * (1 - p)) / (moe * moe);
|
|
1581
|
+
// Finite population correction
|
|
1582
|
+
const n_prop = Math.ceil(n0_prop / (1 + (n0_prop - 1) / N));
|
|
1583
|
+
// ── Sample size for mean (assuming sigma = 0.5 * range, generic) ──
|
|
1584
|
+
// For means: n0 = (z * sigma / moe)^2
|
|
1585
|
+
// We'll estimate sigma from the proportion-based approach
|
|
1586
|
+
const sigmaEstimate = 0.5; // normalized assumption
|
|
1587
|
+
const n0_mean = (z * sigmaEstimate / moe) ** 2;
|
|
1588
|
+
const n_mean = Math.ceil(n0_mean / (1 + (n0_mean - 1) / N));
|
|
1589
|
+
// ── Design effect ──
|
|
1590
|
+
let deff = 1;
|
|
1591
|
+
let adjustedN = n_prop;
|
|
1592
|
+
let designNotes = [];
|
|
1593
|
+
switch (designType) {
|
|
1594
|
+
case 'cluster': {
|
|
1595
|
+
// Typical DEFF for cluster sampling: 1 + (m-1) * rho
|
|
1596
|
+
// Assume average cluster size m=20, ICC rho=0.05
|
|
1597
|
+
const m = 20; // avg cluster size
|
|
1598
|
+
const rho = 0.05; // intraclass correlation
|
|
1599
|
+
deff = 1 + (m - 1) * rho;
|
|
1600
|
+
adjustedN = Math.ceil(n_prop * deff);
|
|
1601
|
+
designNotes = [
|
|
1602
|
+
`Design effect (DEFF) = ${fmt(deff)} (assuming avg cluster size = ${m}, ICC = ${rho})`,
|
|
1603
|
+
`Adjusted sample size = ${adjustedN}`,
|
|
1604
|
+
`Number of clusters needed = ${Math.ceil(adjustedN / m)} (at ${m} per cluster)`,
|
|
1605
|
+
'',
|
|
1606
|
+
'**Key considerations**:',
|
|
1607
|
+
'- Larger clusters or higher ICC increases DEFF',
|
|
1608
|
+
'- DEFF > 2.0 suggests reconsidering cluster size',
|
|
1609
|
+
'- Calculate ICC from pilot data when possible',
|
|
1610
|
+
'- Equal-sized clusters are ideal but rarely achievable',
|
|
1611
|
+
];
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
case 'stratified': {
|
|
1615
|
+
// Stratified sampling generally reduces variance → DEFF < 1
|
|
1616
|
+
deff = 0.8; // typical improvement
|
|
1617
|
+
adjustedN = Math.ceil(n_prop * deff);
|
|
1618
|
+
designNotes = [
|
|
1619
|
+
`Design effect (DEFF) ~ ${fmt(deff)} (stratification typically improves precision)`,
|
|
1620
|
+
`Adjusted sample size = ${adjustedN}`,
|
|
1621
|
+
'',
|
|
1622
|
+
'**Stratification guidance**:',
|
|
1623
|
+
'- Choose strata correlated with outcome variable',
|
|
1624
|
+
'- Proportional allocation: n_h = n * (N_h / N)',
|
|
1625
|
+
'- Optimal allocation: n_h = n * (N_h * sigma_h) / sum(N_h * sigma_h)',
|
|
1626
|
+
'- Neyman allocation maximizes precision for fixed total n',
|
|
1627
|
+
'- Minimum ~30 observations per stratum for stable estimates',
|
|
1628
|
+
'- 3-6 strata typically sufficient; diminishing returns beyond',
|
|
1629
|
+
];
|
|
1630
|
+
break;
|
|
1631
|
+
}
|
|
1632
|
+
default: {
|
|
1633
|
+
deff = 1;
|
|
1634
|
+
adjustedN = n_prop;
|
|
1635
|
+
designNotes = [
|
|
1636
|
+
'Simple random sampling: every unit has equal probability of selection.',
|
|
1637
|
+
'DEFF = 1.0 (baseline reference for other designs).',
|
|
1638
|
+
];
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
// Response rate adjustment
|
|
1642
|
+
const responseRates = [0.9, 0.7, 0.5, 0.3];
|
|
1643
|
+
const adjustedSizes = responseRates.map(rr => ({
|
|
1644
|
+
rate: rr,
|
|
1645
|
+
invites: Math.ceil(adjustedN / rr),
|
|
1646
|
+
}));
|
|
1647
|
+
const lines = [
|
|
1648
|
+
`# Survey Design Calculator`,
|
|
1649
|
+
'',
|
|
1650
|
+
`## Parameters`,
|
|
1651
|
+
`| Parameter | Value |`,
|
|
1652
|
+
`|-----------|-------|`,
|
|
1653
|
+
`| Population size (N) | ${N.toLocaleString()} |`,
|
|
1654
|
+
`| Confidence level | ${(confLevel * 100).toFixed(1)}% (z = ${fmt(z)}) |`,
|
|
1655
|
+
`| Margin of error | ${(moe * 100).toFixed(1)}% |`,
|
|
1656
|
+
`| Expected proportion | ${p} |`,
|
|
1657
|
+
`| Design type | ${designType.replace('_', ' ')} |`,
|
|
1658
|
+
'',
|
|
1659
|
+
`## Sample Size (Proportions)`,
|
|
1660
|
+
`| Step | Value |`,
|
|
1661
|
+
`|------|-------|`,
|
|
1662
|
+
`| n0 (infinite pop) | ${Math.ceil(n0_prop)} |`,
|
|
1663
|
+
`| n (FPC adjusted) | ${n_prop} |`,
|
|
1664
|
+
`| Design effect | ${fmt(deff)} |`,
|
|
1665
|
+
`| **Final sample size** | **${adjustedN}** |`,
|
|
1666
|
+
'',
|
|
1667
|
+
`**Formula**: n0 = z^2 * p(1-p) / E^2 = ${fmt(z)}^2 * ${p}*${fmt(1 - p)} / ${moe}^2 = ${Math.ceil(n0_prop)}`,
|
|
1668
|
+
`**FPC**: n = n0 / (1 + (n0-1)/N) = ${n_prop}`,
|
|
1669
|
+
'',
|
|
1670
|
+
`## Sample Size (Means)`,
|
|
1671
|
+
`Assuming sigma = ${sigmaEstimate}: n = ${n_mean} (FPC adjusted)`,
|
|
1672
|
+
'',
|
|
1673
|
+
`## ${designType.replace('_', ' ').replace(/\b\w/g, c => c.toUpperCase())} Design Notes`,
|
|
1674
|
+
...designNotes,
|
|
1675
|
+
'',
|
|
1676
|
+
`## Response Rate Adjustment`,
|
|
1677
|
+
`| Expected Response Rate | Invitations Needed |`,
|
|
1678
|
+
`|----------------------|-------------------|`,
|
|
1679
|
+
...adjustedSizes.map(a => `| ${(a.rate * 100).toFixed(0)}% | ${a.invites.toLocaleString()} |`),
|
|
1680
|
+
'',
|
|
1681
|
+
`## Margin of Error Sensitivity`,
|
|
1682
|
+
`| MoE | Required n (no DEFF) |`,
|
|
1683
|
+
`|----|---------------------|`,
|
|
1684
|
+
...[0.01, 0.02, 0.03, 0.05, 0.07, 0.10].map(e => {
|
|
1685
|
+
const n0 = (z * z * p * (1 - p)) / (e * e);
|
|
1686
|
+
const nAdj = Math.ceil(n0 / (1 + (n0 - 1) / N));
|
|
1687
|
+
return `| ${(e * 100).toFixed(0)}% | ${nAdj.toLocaleString()} |`;
|
|
1688
|
+
}),
|
|
1689
|
+
'',
|
|
1690
|
+
`## Practical Recommendations`,
|
|
1691
|
+
`- Pilot test with 30-50 respondents before full deployment`,
|
|
1692
|
+
`- Pre-register analysis plan to avoid p-hacking`,
|
|
1693
|
+
`- Use balanced incomplete block designs for long surveys`,
|
|
1694
|
+
`- Include attention checks every 20-30 items`,
|
|
1695
|
+
`- Target median completion time < 15 minutes for web surveys`,
|
|
1696
|
+
`- Offer incentives proportional to survey length`,
|
|
1697
|
+
];
|
|
1698
|
+
return lines.join('\n');
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1701
|
+
// ── 8. Demographic Model ───────────────────────────────────────────────
|
|
1702
|
+
registerTool({
|
|
1703
|
+
name: 'demographic_model',
|
|
1704
|
+
description: 'Population projection using the cohort-component method. Takes age-specific fertility rates, mortality rates, and optional migration. Projects population forward N years with summary statistics including dependency ratios and growth rates.',
|
|
1705
|
+
parameters: {
|
|
1706
|
+
population: { type: 'string', description: 'JSON array of {age_group: string, count: number, fertility_rate: number, mortality_rate: number} objects', required: true },
|
|
1707
|
+
years_forward: { type: 'number', description: 'Number of years to project forward (default 10)' },
|
|
1708
|
+
migration_rate: { type: 'number', description: 'Net migration rate as fraction of population per year (default 0)' },
|
|
1709
|
+
},
|
|
1710
|
+
tier: 'free',
|
|
1711
|
+
async execute(args) {
|
|
1712
|
+
const cohorts = JSON.parse(String(args.population));
|
|
1713
|
+
const yearsForward = args.years_forward !== undefined ? Number(args.years_forward) : 10;
|
|
1714
|
+
const migrationRate = args.migration_rate !== undefined ? Number(args.migration_rate) : 0;
|
|
1715
|
+
if (cohorts.length === 0)
|
|
1716
|
+
return '**Error**: No population data provided.';
|
|
1717
|
+
// Parse age groups to determine interval width
|
|
1718
|
+
// Assume 5-year age groups by default, or detect from labels
|
|
1719
|
+
const ageWidth = 5; // standard 5-year groups
|
|
1720
|
+
let currentPop = cohorts.map(c => ({
|
|
1721
|
+
label: c.age_group,
|
|
1722
|
+
count: c.count,
|
|
1723
|
+
fertilityRate: c.fertility_rate,
|
|
1724
|
+
mortalityRate: c.mortality_rate,
|
|
1725
|
+
}));
|
|
1726
|
+
const totalInitial = sum(currentPop.map(c => c.count));
|
|
1727
|
+
const projections = [
|
|
1728
|
+
{ year: 0, total: totalInitial, cohorts: currentPop.map(c => ({ ...c })) },
|
|
1729
|
+
];
|
|
1730
|
+
// Project forward
|
|
1731
|
+
for (let year = 1; year <= yearsForward; year++) {
|
|
1732
|
+
const newPop = [];
|
|
1733
|
+
// Calculate births (from all fertile cohorts)
|
|
1734
|
+
let totalBirths = 0;
|
|
1735
|
+
for (const cohort of currentPop) {
|
|
1736
|
+
// Fertility rate applied to women (approximate: half the cohort)
|
|
1737
|
+
// Births over the interval = count/2 * fertility_rate * age_width
|
|
1738
|
+
totalBirths += (cohort.count / 2) * cohort.fertilityRate * ageWidth;
|
|
1739
|
+
}
|
|
1740
|
+
// New born cohort (0-4)
|
|
1741
|
+
const infantMortality = currentPop.length > 0 ? currentPop[0].mortalityRate : 0.01;
|
|
1742
|
+
const survivingBirths = totalBirths * (1 - infantMortality * ageWidth);
|
|
1743
|
+
newPop.push({
|
|
1744
|
+
label: currentPop[0]?.label || '0-4',
|
|
1745
|
+
count: Math.max(0, survivingBirths),
|
|
1746
|
+
fertilityRate: 0, // newborns not fertile
|
|
1747
|
+
mortalityRate: infantMortality,
|
|
1748
|
+
});
|
|
1749
|
+
// Age each cohort forward
|
|
1750
|
+
for (let i = 0; i < currentPop.length; i++) {
|
|
1751
|
+
const cohort = currentPop[i];
|
|
1752
|
+
// Survivors = count * (1 - mortality_rate)^ageWidth
|
|
1753
|
+
const survivalRate = Math.pow(1 - cohort.mortalityRate, ageWidth);
|
|
1754
|
+
const survivors = cohort.count * survivalRate;
|
|
1755
|
+
// Apply migration
|
|
1756
|
+
const migrants = cohort.count * migrationRate * ageWidth;
|
|
1757
|
+
if (i + 1 < currentPop.length) {
|
|
1758
|
+
// Move to next age group
|
|
1759
|
+
newPop.push({
|
|
1760
|
+
label: currentPop[i + 1].label,
|
|
1761
|
+
count: Math.max(0, survivors + migrants),
|
|
1762
|
+
fertilityRate: currentPop[i + 1].fertilityRate,
|
|
1763
|
+
mortalityRate: currentPop[i + 1].mortalityRate,
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
else {
|
|
1767
|
+
// Terminal age group (open-ended, e.g. 80+)
|
|
1768
|
+
newPop.push({
|
|
1769
|
+
label: cohort.label,
|
|
1770
|
+
count: Math.max(0, survivors * 0.5 + migrants), // attenuate terminal group
|
|
1771
|
+
fertilityRate: 0,
|
|
1772
|
+
mortalityRate: cohort.mortalityRate,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
const yearTotal = sum(newPop.map(c => c.count));
|
|
1777
|
+
projections.push({ year, total: yearTotal, cohorts: newPop.map(c => ({ ...c })) });
|
|
1778
|
+
currentPop = newPop;
|
|
1779
|
+
}
|
|
1780
|
+
// Calculate demographic indicators
|
|
1781
|
+
const indicators = projections.map(proj => {
|
|
1782
|
+
const total = proj.total;
|
|
1783
|
+
// Assume: young = first 3 groups (0-14), working = groups 3-12 (15-64), elderly = 13+ (65+)
|
|
1784
|
+
const nGroups = proj.cohorts.length;
|
|
1785
|
+
const youngIdx = Math.min(3, nGroups);
|
|
1786
|
+
const elderlyIdx = Math.min(13, nGroups);
|
|
1787
|
+
const young = sum(proj.cohorts.slice(0, youngIdx).map(c => c.count));
|
|
1788
|
+
const working = sum(proj.cohorts.slice(youngIdx, elderlyIdx).map(c => c.count));
|
|
1789
|
+
const elderly = sum(proj.cohorts.slice(elderlyIdx).map(c => c.count));
|
|
1790
|
+
const dependencyRatio = working > 0 ? ((young + elderly) / working) * 100 : 0;
|
|
1791
|
+
const youthDep = working > 0 ? (young / working) * 100 : 0;
|
|
1792
|
+
const elderDep = working > 0 ? (elderly / working) * 100 : 0;
|
|
1793
|
+
return {
|
|
1794
|
+
year: proj.year,
|
|
1795
|
+
total,
|
|
1796
|
+
young,
|
|
1797
|
+
working,
|
|
1798
|
+
elderly,
|
|
1799
|
+
dependencyRatio,
|
|
1800
|
+
youthDep,
|
|
1801
|
+
elderDep,
|
|
1802
|
+
};
|
|
1803
|
+
});
|
|
1804
|
+
// Growth rates
|
|
1805
|
+
const growthRates = indicators.map((ind, i) => {
|
|
1806
|
+
if (i === 0)
|
|
1807
|
+
return 0;
|
|
1808
|
+
return indicators[i - 1].total > 0
|
|
1809
|
+
? ((ind.total - indicators[i - 1].total) / indicators[i - 1].total) * 100
|
|
1810
|
+
: 0;
|
|
1811
|
+
});
|
|
1812
|
+
// TFR (total fertility rate)
|
|
1813
|
+
const tfr = sum(cohorts.map(c => c.fertility_rate)) * ageWidth;
|
|
1814
|
+
const lines = [
|
|
1815
|
+
`# Population Projection (Cohort-Component Method)`,
|
|
1816
|
+
'',
|
|
1817
|
+
`## Initial Parameters`,
|
|
1818
|
+
`| Parameter | Value |`,
|
|
1819
|
+
`|-----------|-------|`,
|
|
1820
|
+
`| Initial population | ${Math.round(totalInitial).toLocaleString()} |`,
|
|
1821
|
+
`| Age groups | ${cohorts.length} |`,
|
|
1822
|
+
`| Projection period | ${yearsForward} years |`,
|
|
1823
|
+
`| Net migration rate | ${(migrationRate * 100).toFixed(2)}% per year |`,
|
|
1824
|
+
`| Total Fertility Rate | ${fmt(tfr)} |`,
|
|
1825
|
+
'',
|
|
1826
|
+
`## Population Trajectory`,
|
|
1827
|
+
`| Year | Total | Growth Rate | Young (0-14) | Working (15-64) | Elderly (65+) | Dep. Ratio |`,
|
|
1828
|
+
`|------|-------|-------------|-------------|-----------------|---------------|-----------|`,
|
|
1829
|
+
...indicators.map((ind, i) => `| ${ind.year} | ${Math.round(ind.total).toLocaleString()} | ${i === 0 ? '-' : fmt(growthRates[i], 2) + '%'} | ${Math.round(ind.young).toLocaleString()} | ${Math.round(ind.working).toLocaleString()} | ${Math.round(ind.elderly).toLocaleString()} | ${fmt(ind.dependencyRatio, 1)}% |`),
|
|
1830
|
+
'',
|
|
1831
|
+
];
|
|
1832
|
+
// Summary comparison
|
|
1833
|
+
const first = indicators[0];
|
|
1834
|
+
const last = indicators[indicators.length - 1];
|
|
1835
|
+
const totalGrowth = first.total > 0 ? ((last.total - first.total) / first.total) * 100 : 0;
|
|
1836
|
+
lines.push(`## Projection Summary`, `| Indicator | Year 0 | Year ${yearsForward} | Change |`, `|-----------|--------|--------|--------|`, `| Total population | ${Math.round(first.total).toLocaleString()} | ${Math.round(last.total).toLocaleString()} | ${totalGrowth >= 0 ? '+' : ''}${fmt(totalGrowth, 1)}% |`, `| Dependency ratio | ${fmt(first.dependencyRatio, 1)}% | ${fmt(last.dependencyRatio, 1)}% | ${fmt(last.dependencyRatio - first.dependencyRatio, 1)}pp |`, `| Youth dependency | ${fmt(first.youthDep, 1)}% | ${fmt(last.youthDep, 1)}% | ${fmt(last.youthDep - first.youthDep, 1)}pp |`, `| Elderly dependency | ${fmt(first.elderDep, 1)}% | ${fmt(last.elderDep, 1)}% | ${fmt(last.elderDep - first.elderDep, 1)}pp |`, '');
|
|
1837
|
+
// Age structure at final year
|
|
1838
|
+
const finalCohorts = projections[projections.length - 1].cohorts;
|
|
1839
|
+
lines.push(`## Final Age Structure (Year ${yearsForward})`, `| Age Group | Count | Share |`, `|-----------|-------|-------|`, ...finalCohorts.map(c => `| ${c.label} | ${Math.round(c.count).toLocaleString()} | ${last.total > 0 ? fmt((c.count / last.total) * 100, 1) : '0'}% |`), '');
|
|
1840
|
+
return lines.join('\n');
|
|
1841
|
+
},
|
|
1842
|
+
});
|
|
1843
|
+
// ── 9. Sentiment Analysis ──────────────────────────────────────────────
|
|
1844
|
+
registerTool({
|
|
1845
|
+
name: 'sentiment_analyze',
|
|
1846
|
+
description: 'Rule-based sentiment analysis (VADER-like). Scores text on positive/negative/neutral/compound dimensions. Handles negation, degree modifiers, punctuation emphasis, capitalization boost, and contrastive conjunctions. Works on single texts or arrays of texts. Embeds a ~500 word sentiment lexicon.',
|
|
1847
|
+
parameters: {
|
|
1848
|
+
text: { type: 'string', description: 'Text to analyze, or JSON array of strings for batch analysis', required: true },
|
|
1849
|
+
method: { type: 'string', description: 'Analysis method: vader (full features) or simple (word matching only). Default: vader' },
|
|
1850
|
+
},
|
|
1851
|
+
tier: 'free',
|
|
1852
|
+
async execute(args) {
|
|
1853
|
+
const rawText = String(args.text);
|
|
1854
|
+
const method = (args.method ? String(args.method) : 'vader').toLowerCase().trim();
|
|
1855
|
+
// Try parsing as JSON array
|
|
1856
|
+
let texts;
|
|
1857
|
+
try {
|
|
1858
|
+
const parsed = JSON.parse(rawText);
|
|
1859
|
+
texts = Array.isArray(parsed) ? parsed.map(String) : [rawText];
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
texts = [rawText];
|
|
1863
|
+
}
|
|
1864
|
+
function analyzeOne(text) {
|
|
1865
|
+
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);
|
|
1866
|
+
if (sentences.length === 0) {
|
|
1867
|
+
return { compound: 0, positive: 0, negative: 0, neutral: 0, wordScores: [] };
|
|
1868
|
+
}
|
|
1869
|
+
let allScores = [];
|
|
1870
|
+
const wordScores = [];
|
|
1871
|
+
for (const sentence of sentences) {
|
|
1872
|
+
const words = sentence.toLowerCase().replace(/[^a-z\s'-]/g, ' ').split(/\s+/).filter(w => w.length > 0);
|
|
1873
|
+
const isAllCaps = sentence === sentence.toUpperCase() && sentence !== sentence.toLowerCase();
|
|
1874
|
+
for (let i = 0; i < words.length; i++) {
|
|
1875
|
+
const word = words[i];
|
|
1876
|
+
let score = SENTIMENT_LEXICON[word];
|
|
1877
|
+
if (score === undefined)
|
|
1878
|
+
continue;
|
|
1879
|
+
if (method === 'vader') {
|
|
1880
|
+
// Check for negation in previous 3 words
|
|
1881
|
+
let negated = false;
|
|
1882
|
+
for (let j = Math.max(0, i - 3); j < i; j++) {
|
|
1883
|
+
if (NEGATION_WORDS.has(words[j])) {
|
|
1884
|
+
negated = true;
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
if (negated)
|
|
1889
|
+
score *= -0.74; // VADER uses ~0.74 damping
|
|
1890
|
+
// Check for degree modifiers in previous 2 words
|
|
1891
|
+
for (let j = Math.max(0, i - 2); j < i; j++) {
|
|
1892
|
+
const mod = DEGREE_MODIFIERS[words[j]];
|
|
1893
|
+
if (mod !== undefined) {
|
|
1894
|
+
score *= mod;
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
// Capitalization boost
|
|
1899
|
+
if (isAllCaps) {
|
|
1900
|
+
score *= 1.15;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
// Exclamation marks amplify
|
|
1904
|
+
const excl = (sentence.match(/!/g) || []).length;
|
|
1905
|
+
if (excl > 0 && method === 'vader') {
|
|
1906
|
+
score += Math.sign(score) * Math.min(excl, 4) * 0.292;
|
|
1907
|
+
}
|
|
1908
|
+
allScores.push(score);
|
|
1909
|
+
wordScores.push({ word, score });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
if (allScores.length === 0) {
|
|
1913
|
+
return { compound: 0, positive: 0, negative: 0, neutral: 0, wordScores: [] };
|
|
1914
|
+
}
|
|
1915
|
+
// Compound score: normalized sum
|
|
1916
|
+
const rawSum = sum(allScores);
|
|
1917
|
+
const compound = rawSum / Math.sqrt(rawSum * rawSum + 15); // VADER normalization constant ~15
|
|
1918
|
+
// Proportion scores
|
|
1919
|
+
const posSum = sum(allScores.filter(s => s > 0));
|
|
1920
|
+
const negSum = Math.abs(sum(allScores.filter(s => s < 0)));
|
|
1921
|
+
const neuCount = allScores.filter(s => s === 0).length;
|
|
1922
|
+
const totalMag = posSum + negSum + neuCount;
|
|
1923
|
+
const positive = totalMag > 0 ? posSum / totalMag : 0;
|
|
1924
|
+
const negative = totalMag > 0 ? negSum / totalMag : 0;
|
|
1925
|
+
const neutral = totalMag > 0 ? neuCount / totalMag : 0;
|
|
1926
|
+
return { compound, positive, negative, neutral, wordScores };
|
|
1927
|
+
}
|
|
1928
|
+
const results = texts.map(t => ({ text: t, ...analyzeOne(t) }));
|
|
1929
|
+
const lines = [`# Sentiment Analysis (${method.toUpperCase()})`, ''];
|
|
1930
|
+
if (results.length === 1) {
|
|
1931
|
+
const r = results[0];
|
|
1932
|
+
let sentiment;
|
|
1933
|
+
if (r.compound >= 0.05)
|
|
1934
|
+
sentiment = 'Positive';
|
|
1935
|
+
else if (r.compound <= -0.05)
|
|
1936
|
+
sentiment = 'Negative';
|
|
1937
|
+
else
|
|
1938
|
+
sentiment = 'Neutral';
|
|
1939
|
+
lines.push(`## Result: **${sentiment}**`, '', `| Dimension | Score |`, `|-----------|-------|`, `| Compound | ${fmt(r.compound)} |`, `| Positive | ${fmt(r.positive)} |`, `| Negative | ${fmt(r.negative)} |`, `| Neutral | ${fmt(r.neutral)} |`, '');
|
|
1940
|
+
if (r.wordScores.length > 0) {
|
|
1941
|
+
lines.push(`## Word-Level Scores`, `| Word | Score |`, `|------|-------|`, ...r.wordScores.map(ws => `| ${ws.word} | ${fmt(ws.score)} |`), '');
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
else {
|
|
1945
|
+
// Batch mode
|
|
1946
|
+
lines.push(`## Batch Results (${results.length} texts)`, '', `| # | Text (truncated) | Compound | Sentiment |`, `|---|-----------------|----------|-----------|`, ...results.map((r, i) => {
|
|
1947
|
+
const trunc = r.text.length > 50 ? r.text.slice(0, 50) + '...' : r.text;
|
|
1948
|
+
const sent = r.compound >= 0.05 ? 'Positive' : r.compound <= -0.05 ? 'Negative' : 'Neutral';
|
|
1949
|
+
return `| ${i + 1} | ${trunc.replace(/\|/g, '/')} | ${fmt(r.compound)} | ${sent} |`;
|
|
1950
|
+
}), '');
|
|
1951
|
+
// Aggregate stats
|
|
1952
|
+
const compounds = results.map(r => r.compound);
|
|
1953
|
+
const posCount = compounds.filter(c => c >= 0.05).length;
|
|
1954
|
+
const negCount = compounds.filter(c => c <= -0.05).length;
|
|
1955
|
+
const neuCount = compounds.filter(c => c > -0.05 && c < 0.05).length;
|
|
1956
|
+
lines.push(`## Aggregate`, `| Metric | Value |`, `|--------|-------|`, `| Mean compound | ${fmt(mean(compounds))} |`, `| Median compound | ${fmt(median(compounds))} |`, `| SD compound | ${fmt(stddev(compounds))} |`, `| Positive texts | ${posCount} (${fmt(100 * posCount / results.length, 1)}%) |`, `| Negative texts | ${negCount} (${fmt(100 * negCount / results.length, 1)}%) |`, `| Neutral texts | ${neuCount} (${fmt(100 * neuCount / results.length, 1)}%) |`, '');
|
|
1957
|
+
}
|
|
1958
|
+
lines.push(`## Methodology`, `- Lexicon: ~500 words with human-rated valence scores (-3.5 to +3.5)`, `- Negation detection: flips polarity within 3-word window (damping factor: 0.74)`, `- Degree modifiers: amplify/dampen based on intensifier/downtoner words`, `- Compound score: sum / sqrt(sum^2 + alpha), where alpha = 15`, `- Thresholds: compound >= 0.05 = positive, <= -0.05 = negative, else neutral`);
|
|
1959
|
+
return lines.join('\n');
|
|
1960
|
+
},
|
|
1961
|
+
});
|
|
1962
|
+
// ── 10. Voting System Analysis ─────────────────────────────────────────
|
|
1963
|
+
registerTool({
|
|
1964
|
+
name: 'voting_system',
|
|
1965
|
+
description: 'Analyze elections under multiple voting systems. Given ranked preference ballots, compute results under: plurality (first-past-the-post), runoff (top-two), instant-runoff (IRV/RCV), Borda count, Condorcet (pairwise majority), and approval voting. Shows how different systems produce different winners.',
|
|
1966
|
+
parameters: {
|
|
1967
|
+
ballots: { type: 'string', description: 'JSON array of ranked preference arrays, e.g. [["A","B","C"],["B","A","C"]]', required: true },
|
|
1968
|
+
candidates: { type: 'string', description: 'Comma-separated candidate names', required: true },
|
|
1969
|
+
systems: { type: 'string', description: 'Which systems: all, plurality, irv, borda, condorcet, approval (default: all)' },
|
|
1970
|
+
},
|
|
1971
|
+
tier: 'free',
|
|
1972
|
+
async execute(args) {
|
|
1973
|
+
const ballots = JSON.parse(String(args.ballots));
|
|
1974
|
+
const candidateList = String(args.candidates).split(',').map(s => s.trim());
|
|
1975
|
+
const systems = (args.systems ? String(args.systems) : 'all').toLowerCase().trim();
|
|
1976
|
+
const nVoters = ballots.length;
|
|
1977
|
+
const nCandidates = candidateList.length;
|
|
1978
|
+
if (nVoters === 0)
|
|
1979
|
+
return '**Error**: No ballots provided.';
|
|
1980
|
+
if (nCandidates < 2)
|
|
1981
|
+
return '**Error**: Need at least 2 candidates.';
|
|
1982
|
+
const showAll = systems === 'all';
|
|
1983
|
+
const lines = [
|
|
1984
|
+
`# Election Analysis`,
|
|
1985
|
+
'',
|
|
1986
|
+
`- **Voters**: ${nVoters}`,
|
|
1987
|
+
`- **Candidates**: ${candidateList.join(', ')}`,
|
|
1988
|
+
'',
|
|
1989
|
+
];
|
|
1990
|
+
const winners = [];
|
|
1991
|
+
// ── Plurality ──
|
|
1992
|
+
if (showAll || systems === 'plurality') {
|
|
1993
|
+
const counts = new Map();
|
|
1994
|
+
candidateList.forEach(c => counts.set(c, 0));
|
|
1995
|
+
for (const ballot of ballots) {
|
|
1996
|
+
if (ballot.length > 0 && counts.has(ballot[0])) {
|
|
1997
|
+
counts.set(ballot[0], counts.get(ballot[0]) + 1);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
2001
|
+
const winner = sorted[0][0];
|
|
2002
|
+
winners.push({ system: 'Plurality', winner });
|
|
2003
|
+
lines.push(`## Plurality (First-Past-the-Post)`, `| Candidate | Votes | Share |`, `|-----------|-------|-------|`, ...sorted.map(([c, v]) => `| ${c} | ${v} | ${fmt(100 * v / nVoters, 1)}% |`), '', `**Winner**: ${winner} (${sorted[0][1]} votes, ${fmt(100 * sorted[0][1] / nVoters, 1)}%)`, sorted[0][1] <= nVoters / 2 ? `> Note: Winner has only a plurality, not a majority.` : '', '');
|
|
2004
|
+
}
|
|
2005
|
+
// ── Runoff (top-two) ──
|
|
2006
|
+
if (showAll || systems === 'runoff') {
|
|
2007
|
+
const round1 = new Map();
|
|
2008
|
+
candidateList.forEach(c => round1.set(c, 0));
|
|
2009
|
+
for (const ballot of ballots) {
|
|
2010
|
+
if (ballot.length > 0 && round1.has(ballot[0])) {
|
|
2011
|
+
round1.set(ballot[0], round1.get(ballot[0]) + 1);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
const sorted1 = [...round1.entries()].sort((a, b) => b[1] - a[1]);
|
|
2015
|
+
// Check if first-round majority
|
|
2016
|
+
if (sorted1[0][1] > nVoters / 2) {
|
|
2017
|
+
winners.push({ system: 'Runoff', winner: sorted1[0][0] });
|
|
2018
|
+
lines.push(`## Top-Two Runoff`, `First-round majority: **${sorted1[0][0]}** wins outright.`, '');
|
|
2019
|
+
}
|
|
2020
|
+
else {
|
|
2021
|
+
const top2 = [sorted1[0][0], sorted1[1][0]];
|
|
2022
|
+
const round2 = new Map();
|
|
2023
|
+
top2.forEach(c => round2.set(c, 0));
|
|
2024
|
+
for (const ballot of ballots) {
|
|
2025
|
+
for (const choice of ballot) {
|
|
2026
|
+
if (top2.includes(choice)) {
|
|
2027
|
+
round2.set(choice, round2.get(choice) + 1);
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const sorted2 = [...round2.entries()].sort((a, b) => b[1] - a[1]);
|
|
2033
|
+
const winner = sorted2[0][0];
|
|
2034
|
+
winners.push({ system: 'Runoff', winner });
|
|
2035
|
+
lines.push(`## Top-Two Runoff`, `**Round 1**: ${sorted1.map(([c, v]) => `${c}=${v}`).join(', ')}`, `**Round 2** (${top2.join(' vs ')}): ${sorted2.map(([c, v]) => `${c}=${v}`).join(', ')}`, `**Winner**: ${winner}`, '');
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
// ── Instant-Runoff Voting (IRV) ──
|
|
2039
|
+
if (showAll || systems === 'irv') {
|
|
2040
|
+
let remaining = new Set(candidateList);
|
|
2041
|
+
let currentBallots = ballots.map(b => [...b]);
|
|
2042
|
+
const rounds = [];
|
|
2043
|
+
let irvWinner = null;
|
|
2044
|
+
let round = 1;
|
|
2045
|
+
while (remaining.size > 1) {
|
|
2046
|
+
const counts = new Map();
|
|
2047
|
+
remaining.forEach(c => counts.set(c, 0));
|
|
2048
|
+
for (const ballot of currentBallots) {
|
|
2049
|
+
for (const choice of ballot) {
|
|
2050
|
+
if (remaining.has(choice)) {
|
|
2051
|
+
counts.set(choice, counts.get(choice) + 1);
|
|
2052
|
+
break;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
2057
|
+
// Check majority
|
|
2058
|
+
if (sorted[0][1] > nVoters / 2) {
|
|
2059
|
+
irvWinner = sorted[0][0];
|
|
2060
|
+
rounds.push({ round, counts });
|
|
2061
|
+
break;
|
|
2062
|
+
}
|
|
2063
|
+
// Eliminate candidate with fewest votes
|
|
2064
|
+
const minVotes = sorted[sorted.length - 1][1];
|
|
2065
|
+
const toEliminate = sorted.filter(([_, v]) => v === minVotes).map(([c]) => c);
|
|
2066
|
+
const eliminated = toEliminate[toEliminate.length - 1]; // eliminate last alphabetically if tie
|
|
2067
|
+
rounds.push({ round, counts, eliminated });
|
|
2068
|
+
remaining.delete(eliminated);
|
|
2069
|
+
round++;
|
|
2070
|
+
if (remaining.size === 1) {
|
|
2071
|
+
irvWinner = [...remaining][0];
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
if (!irvWinner && remaining.size === 1)
|
|
2075
|
+
irvWinner = [...remaining][0];
|
|
2076
|
+
if (irvWinner)
|
|
2077
|
+
winners.push({ system: 'IRV', winner: irvWinner });
|
|
2078
|
+
lines.push(`## Instant-Runoff Voting (IRV)`);
|
|
2079
|
+
for (const r of rounds) {
|
|
2080
|
+
const sorted = [...r.counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
2081
|
+
lines.push(`**Round ${r.round}**: ${sorted.map(([c, v]) => `${c}=${v}`).join(', ')}` +
|
|
2082
|
+
(r.eliminated ? ` → Eliminate ${r.eliminated}` : ''));
|
|
2083
|
+
}
|
|
2084
|
+
lines.push(`**Winner**: ${irvWinner || 'None'}`, '');
|
|
2085
|
+
}
|
|
2086
|
+
// ── Borda Count ──
|
|
2087
|
+
if (showAll || systems === 'borda') {
|
|
2088
|
+
const scores = new Map();
|
|
2089
|
+
candidateList.forEach(c => scores.set(c, 0));
|
|
2090
|
+
for (const ballot of ballots) {
|
|
2091
|
+
for (let i = 0; i < ballot.length; i++) {
|
|
2092
|
+
if (scores.has(ballot[i])) {
|
|
2093
|
+
// Borda: n-1 points for 1st, n-2 for 2nd, etc.
|
|
2094
|
+
scores.set(ballot[i], scores.get(ballot[i]) + (nCandidates - 1 - i));
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
|
|
2099
|
+
const winner = sorted[0][0];
|
|
2100
|
+
winners.push({ system: 'Borda Count', winner });
|
|
2101
|
+
lines.push(`## Borda Count`, `| Candidate | Points | Avg Rank |`, `|-----------|--------|----------|`, ...sorted.map(([c, pts]) => `| ${c} | ${pts} | ${fmt(nCandidates - pts / nVoters, 2)} |`), '', `**Winner**: ${winner} (${sorted[0][1]} points)`, '');
|
|
2102
|
+
}
|
|
2103
|
+
// ── Condorcet ──
|
|
2104
|
+
if (showAll || systems === 'condorcet') {
|
|
2105
|
+
// Pairwise comparison matrix
|
|
2106
|
+
const pairwise = new Map();
|
|
2107
|
+
candidateList.forEach(c => {
|
|
2108
|
+
pairwise.set(c, new Map());
|
|
2109
|
+
candidateList.forEach(d => pairwise.get(c).set(d, 0));
|
|
2110
|
+
});
|
|
2111
|
+
for (const ballot of ballots) {
|
|
2112
|
+
for (let i = 0; i < ballot.length; i++) {
|
|
2113
|
+
for (let j = i + 1; j < ballot.length; j++) {
|
|
2114
|
+
if (pairwise.has(ballot[i]) && pairwise.get(ballot[i]).has(ballot[j])) {
|
|
2115
|
+
pairwise.get(ballot[i]).set(ballot[j], pairwise.get(ballot[i]).get(ballot[j]) + 1);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
// Find Condorcet winner: beats all others in pairwise
|
|
2121
|
+
let condorcetWinner = null;
|
|
2122
|
+
for (const c of candidateList) {
|
|
2123
|
+
let beatsAll = true;
|
|
2124
|
+
for (const d of candidateList) {
|
|
2125
|
+
if (c === d)
|
|
2126
|
+
continue;
|
|
2127
|
+
const cVsD = pairwise.get(c).get(d);
|
|
2128
|
+
const dVsC = pairwise.get(d).get(c);
|
|
2129
|
+
if (cVsD <= dVsC) {
|
|
2130
|
+
beatsAll = false;
|
|
2131
|
+
break;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
if (beatsAll) {
|
|
2135
|
+
condorcetWinner = c;
|
|
2136
|
+
break;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (condorcetWinner)
|
|
2140
|
+
winners.push({ system: 'Condorcet', winner: condorcetWinner });
|
|
2141
|
+
lines.push(`## Condorcet (Pairwise Majority)`, `### Pairwise Matrix (row beats column by N votes)`, `| | ${candidateList.join(' | ')} |`, `|${'-|'.repeat(nCandidates + 1)}`, ...candidateList.map(c => `| **${c}** | ${candidateList.map(d => c === d ? '-' : `${pairwise.get(c).get(d)}`).join(' | ')} |`), '', condorcetWinner
|
|
2142
|
+
? `**Condorcet winner**: ${condorcetWinner} (beats all others head-to-head)`
|
|
2143
|
+
: `**No Condorcet winner** (cycle detected — Condorcet paradox)`, '');
|
|
2144
|
+
}
|
|
2145
|
+
// ── Approval Voting ──
|
|
2146
|
+
if (showAll || systems === 'approval') {
|
|
2147
|
+
// Approval: each voter "approves" top ceil(n/2) candidates from their ranking
|
|
2148
|
+
const approveCount = Math.ceil(nCandidates / 2);
|
|
2149
|
+
const approvals = new Map();
|
|
2150
|
+
candidateList.forEach(c => approvals.set(c, 0));
|
|
2151
|
+
for (const ballot of ballots) {
|
|
2152
|
+
for (let i = 0; i < Math.min(approveCount, ballot.length); i++) {
|
|
2153
|
+
if (approvals.has(ballot[i])) {
|
|
2154
|
+
approvals.set(ballot[i], approvals.get(ballot[i]) + 1);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
const sorted = [...approvals.entries()].sort((a, b) => b[1] - a[1]);
|
|
2159
|
+
const winner = sorted[0][0];
|
|
2160
|
+
winners.push({ system: 'Approval', winner });
|
|
2161
|
+
lines.push(`## Approval Voting`, `> Each voter approves top ${approveCount} of ${nCandidates} candidates.`, '', `| Candidate | Approvals | Rate |`, `|-----------|-----------|------|`, ...sorted.map(([c, v]) => `| ${c} | ${v} | ${fmt(100 * v / nVoters, 1)}% |`), '', `**Winner**: ${winner} (${sorted[0][1]} approvals)`, '');
|
|
2162
|
+
}
|
|
2163
|
+
// ── Comparison ──
|
|
2164
|
+
if (winners.length > 1) {
|
|
2165
|
+
const uniqueWinners = [...new Set(winners.map(w => w.winner))];
|
|
2166
|
+
lines.push(`## System Comparison`, `| System | Winner |`, `|--------|--------|`, ...winners.map(w => `| ${w.system} | ${w.winner} |`), '');
|
|
2167
|
+
if (uniqueWinners.length === 1) {
|
|
2168
|
+
lines.push(`All systems agree: **${uniqueWinners[0]}** wins under every method.`);
|
|
2169
|
+
}
|
|
2170
|
+
else {
|
|
2171
|
+
lines.push(`**Different systems produce different winners!**`, `This demonstrates Arrow's impossibility theorem in practice:`, `no ranked voting system can satisfy all fairness criteria simultaneously.`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return lines.join('\n');
|
|
2175
|
+
},
|
|
2176
|
+
});
|
|
2177
|
+
// ── 11. Behavioral Experiment Design ───────────────────────────────────
|
|
2178
|
+
registerTool({
|
|
2179
|
+
name: 'experiment_behavioral',
|
|
2180
|
+
description: 'Design behavioral and social science experiments. Generates between/within/mixed/quasi-experimental designs with counterbalancing schemes (Latin square), randomization procedures, manipulation checks, demand characteristics mitigation, power analysis, and IRB consideration checklist.',
|
|
2181
|
+
parameters: {
|
|
2182
|
+
research_question: { type: 'string', description: 'The research question to investigate', required: true },
|
|
2183
|
+
design_type: { type: 'string', description: 'Design: between, within, mixed, quasi', required: true },
|
|
2184
|
+
conditions: { type: 'number', description: 'Number of experimental conditions', required: true },
|
|
2185
|
+
participants_per_condition: { type: 'number', description: 'Planned participants per condition (default: 30)' },
|
|
2186
|
+
},
|
|
2187
|
+
tier: 'free',
|
|
2188
|
+
async execute(args) {
|
|
2189
|
+
const rq = String(args.research_question);
|
|
2190
|
+
const design = String(args.design_type).toLowerCase().trim();
|
|
2191
|
+
const nConditions = Number(args.conditions);
|
|
2192
|
+
const nPerCondition = args.participants_per_condition ? Number(args.participants_per_condition) : 30;
|
|
2193
|
+
if (nConditions < 2)
|
|
2194
|
+
return '**Error**: Need at least 2 conditions.';
|
|
2195
|
+
const condLabels = Array.from({ length: nConditions }, (_, i) => `Condition ${String.fromCharCode(65 + i)}`);
|
|
2196
|
+
// Total N based on design
|
|
2197
|
+
let totalN;
|
|
2198
|
+
let designLabel;
|
|
2199
|
+
let designDescription;
|
|
2200
|
+
switch (design) {
|
|
2201
|
+
case 'between':
|
|
2202
|
+
totalN = nConditions * nPerCondition;
|
|
2203
|
+
designLabel = 'Between-Subjects';
|
|
2204
|
+
designDescription = 'Each participant is assigned to exactly one condition. Different participants in each group.';
|
|
2205
|
+
break;
|
|
2206
|
+
case 'within':
|
|
2207
|
+
totalN = nPerCondition; // same participants do all conditions
|
|
2208
|
+
designLabel = 'Within-Subjects (Repeated Measures)';
|
|
2209
|
+
designDescription = 'Each participant experiences all conditions. Requires counterbalancing to control order effects.';
|
|
2210
|
+
break;
|
|
2211
|
+
case 'mixed':
|
|
2212
|
+
totalN = Math.ceil(nConditions / 2) * nPerCondition;
|
|
2213
|
+
designLabel = 'Mixed Design';
|
|
2214
|
+
designDescription = 'Combines between- and within-subjects factors. Some variables varied between groups, others within.';
|
|
2215
|
+
break;
|
|
2216
|
+
case 'quasi':
|
|
2217
|
+
totalN = nConditions * nPerCondition;
|
|
2218
|
+
designLabel = 'Quasi-Experimental';
|
|
2219
|
+
designDescription = 'Groups are pre-existing (not randomly assigned). Requires careful control for confounds.';
|
|
2220
|
+
break;
|
|
2221
|
+
default:
|
|
2222
|
+
return `**Error**: Unknown design type. Use: between, within, mixed, quasi`;
|
|
2223
|
+
}
|
|
2224
|
+
// Power analysis (simplified: for t-test comparing 2 groups at d=0.5)
|
|
2225
|
+
// n per group for 80% power, alpha=0.05, d=0.5: ~64
|
|
2226
|
+
// For F-test with k groups: n per group ~= (z_alpha + z_beta)^2 * 2 / f^2
|
|
2227
|
+
const fSmall = 0.1, fMedium = 0.25, fLarge = 0.4;
|
|
2228
|
+
const z_alpha = 1.96, z_beta = 0.84;
|
|
2229
|
+
const nSmall = Math.ceil(((z_alpha + z_beta) ** 2 * 2) / (fSmall ** 2 * nConditions));
|
|
2230
|
+
const nMedium = Math.ceil(((z_alpha + z_beta) ** 2 * 2) / (fMedium ** 2 * nConditions));
|
|
2231
|
+
const nLarge = Math.ceil(((z_alpha + z_beta) ** 2 * 2) / (fLarge ** 2 * nConditions));
|
|
2232
|
+
// Latin square counterbalancing
|
|
2233
|
+
function latinSquare(k) {
|
|
2234
|
+
const square = [];
|
|
2235
|
+
for (let i = 0; i < k; i++) {
|
|
2236
|
+
const row = [];
|
|
2237
|
+
for (let j = 0; j < k; j++) {
|
|
2238
|
+
row.push((i + j) % k);
|
|
2239
|
+
}
|
|
2240
|
+
square.push(row);
|
|
2241
|
+
}
|
|
2242
|
+
return square;
|
|
2243
|
+
}
|
|
2244
|
+
const ls = latinSquare(nConditions);
|
|
2245
|
+
const lines = [
|
|
2246
|
+
`# Behavioral Experiment Design`,
|
|
2247
|
+
'',
|
|
2248
|
+
`## Research Question`,
|
|
2249
|
+
`> ${rq}`,
|
|
2250
|
+
'',
|
|
2251
|
+
`## Design Overview`,
|
|
2252
|
+
`| Parameter | Value |`,
|
|
2253
|
+
`|-----------|-------|`,
|
|
2254
|
+
`| Design type | **${designLabel}** |`,
|
|
2255
|
+
`| Conditions | ${nConditions} (${condLabels.join(', ')}) |`,
|
|
2256
|
+
`| N per condition | ${nPerCondition} |`,
|
|
2257
|
+
`| Total N | ${totalN} |`,
|
|
2258
|
+
'',
|
|
2259
|
+
designDescription,
|
|
2260
|
+
'',
|
|
2261
|
+
`## Randomization Procedure`,
|
|
2262
|
+
];
|
|
2263
|
+
if (design === 'between') {
|
|
2264
|
+
lines.push(`1. Generate participant IDs (P001 to P${String(totalN).padStart(3, '0')})`, `2. Use block randomization (block size = ${nConditions * 2}) to assign participants`, `3. Within each block, equal allocation to all ${nConditions} conditions`, `4. Stratify by key demographics (age, gender) if relevant`, `5. Concealed allocation: use sealed envelopes or computer-generated sequence`, '');
|
|
2265
|
+
}
|
|
2266
|
+
else if (design === 'within') {
|
|
2267
|
+
lines.push(`1. All ${totalN} participants complete all ${nConditions} conditions`, `2. Use Latin Square counterbalancing (see below)`, `3. Assign participants to counterbalancing orders in rotation`, `4. Include sufficient washout period between conditions`, '', `### Latin Square Counterbalancing`, `| Order | ${condLabels.map((_, i) => `Position ${i + 1}`).join(' | ')} |`, `|${'-|'.repeat(nConditions + 1)}`, ...ls.map((row, i) => `| Order ${i + 1} | ${row.map(j => condLabels[j]).join(' | ')} |`), '', `Assign ${Math.ceil(totalN / nConditions)} participants to each order.`, '');
|
|
2268
|
+
}
|
|
2269
|
+
else if (design === 'mixed') {
|
|
2270
|
+
lines.push(`1. Between-subjects factor: randomly assign to ${Math.ceil(nConditions / 2)} groups`, `2. Within-subjects factor: each group completes ${Math.floor(nConditions / 2) + 1} conditions`, `3. Counterbalance within-subjects conditions across groups`, '');
|
|
2271
|
+
}
|
|
2272
|
+
else {
|
|
2273
|
+
lines.push(`1. Identify pre-existing groups for quasi-experimental comparison`, `2. Match groups on key covariates (propensity score matching if possible)`, `3. Document baseline equivalence checks`, `4. Plan for difference-in-differences or regression discontinuity analysis`, '');
|
|
2274
|
+
}
|
|
2275
|
+
lines.push(`## Power Analysis`, `| Effect Size (f) | Label | N per condition needed | Total N needed |`, `|-----------------|-------|-----------------------|----------------|`, `| ${fSmall} | Small | ${nSmall} | ${nSmall * nConditions} |`, `| ${fMedium} | Medium | ${nMedium} | ${nMedium * nConditions} |`, `| ${fLarge} | Large | ${nLarge} | ${nLarge * nConditions} |`, '', `Your planned N (${nPerCondition}/condition): sufficient for ${nPerCondition >= nSmall ? 'small' : nPerCondition >= nMedium ? 'medium' : nPerCondition >= nLarge ? 'large' : 'very large'} effects.`, '', `## Statistical Analysis Plan`);
|
|
2276
|
+
switch (design) {
|
|
2277
|
+
case 'between':
|
|
2278
|
+
lines.push(nConditions === 2
|
|
2279
|
+
? `- **Primary**: Independent samples t-test (or Welch's t-test)`
|
|
2280
|
+
: `- **Primary**: One-way ANOVA (F-test)`, `- **Post-hoc**: Tukey HSD (if ANOVA significant)`, `- **Effect size**: Cohen's d (2 groups) or eta-squared (3+ groups)`, `- **Assumption checks**: Shapiro-Wilk (normality), Levene's test (homogeneity)`, `- **Non-parametric fallback**: ${nConditions === 2 ? 'Mann-Whitney U' : 'Kruskal-Wallis H'}`);
|
|
2281
|
+
break;
|
|
2282
|
+
case 'within':
|
|
2283
|
+
lines.push(nConditions === 2
|
|
2284
|
+
? `- **Primary**: Paired samples t-test`
|
|
2285
|
+
: `- **Primary**: Repeated measures ANOVA`, `- **Sphericity**: Mauchly's test (RM-ANOVA), Greenhouse-Geisser correction if violated`, `- **Post-hoc**: Bonferroni-corrected pairwise comparisons`, `- **Effect size**: Cohen's d_z (paired) or partial eta-squared`, `- **Non-parametric fallback**: ${nConditions === 2 ? 'Wilcoxon signed-rank' : "Friedman's test"}`);
|
|
2286
|
+
break;
|
|
2287
|
+
case 'mixed':
|
|
2288
|
+
lines.push(`- **Primary**: Mixed-design ANOVA`, `- **Test**: Main effects for both factors + interaction`, `- **Simple effects**: Decompose interaction if significant`, `- **Effect size**: Partial eta-squared for each effect`);
|
|
2289
|
+
break;
|
|
2290
|
+
case 'quasi':
|
|
2291
|
+
lines.push(`- **Primary**: ANCOVA (controlling for baseline differences)`, `- **Sensitivity**: Propensity score matching or weighting`, `- **Robustness**: Difference-in-differences if pre-post data available`, `- **Caution**: Cannot infer causation without randomization`);
|
|
2292
|
+
break;
|
|
2293
|
+
}
|
|
2294
|
+
lines.push('', `## Manipulation Checks`, `- Include ${nConditions} manipulation check items after experimental manipulation`, `- Ask participants to recall/identify their condition (comprehension check)`, `- Rate perceived [relevant construct] on 7-point scale`, `- Verify manipulation before running main analyses`, `- Exclude participants failing manipulation checks (report both with/without exclusions)`, '', `## Demand Characteristics Mitigation`, `- Use a cover story unrelated to the true hypothesis`, `- Include filler items/tasks to obscure the focal measure`, `- Administer funnel debriefing questionnaire post-study:`, ` 1. What do you think this study was about?`, ` 2. Did you notice anything unusual?`, ` 3. Do you think your behavior was influenced by anything specific?`, `- Consider single-blind (participant) or double-blind (both) design`, `- Use behavioral measures in addition to self-report`, '', `## IRB Considerations Checklist`, `- [ ] Informed consent document prepared`, `- [ ] Deception disclosure and debriefing plan (if deception used)`, `- [ ] Risk assessment: physical, psychological, social, economic`, `- [ ] Vulnerable population protections (if applicable)`, `- [ ] Data anonymization/de-identification plan`, `- [ ] Data storage and retention policy (typically 5-7 years)`, `- [ ] Right to withdraw without penalty`, `- [ ] Compensation plan (fair, not coercive)`, `- [ ] Adverse event reporting procedure`, `- [ ] Privacy and confidentiality safeguards`, `- [ ] Pre-registration plan (OSF, AsPredicted, or ClinicalTrials.gov)`, '', `## Pre-Registration Template`, `1. **Hypothesis**: [State directional hypothesis]`, `2. **Design**: ${designLabel}, ${nConditions} conditions`, `3. **Planned N**: ${totalN} (${nPerCondition} per condition)`, `4. **Stopping rule**: Data collection stops at planned N`, `5. **Primary DV**: [Specify]`, `6. **Primary analysis**: [Specify test]`, `7. **Exclusion criteria**: [Specify a priori]`, `8. **Alpha**: .05 (two-tailed)`, `9. **Multiple comparison correction**: ${nConditions > 2 ? 'Bonferroni / Holm' : 'N/A (2 conditions)'}`);
|
|
2295
|
+
return lines.join('\n');
|
|
2296
|
+
},
|
|
2297
|
+
});
|
|
2298
|
+
// ── 12. Discourse Analysis ─────────────────────────────────────────────
|
|
2299
|
+
registerTool({
|
|
2300
|
+
name: 'discourse_analyze',
|
|
2301
|
+
description: 'Comprehensive text/discourse analysis: word frequency with TF-IDF, type-token ratio, readability scores (Flesch-Kincaid Grade, Gunning Fog, Coleman-Liau, SMOG, Automated Readability Index), sentence length distribution, and vocabulary diversity (Yule\'s K, Simpson\'s D, hapax legomena ratio).',
|
|
2302
|
+
parameters: {
|
|
2303
|
+
text: { type: 'string', description: 'Text to analyze', required: true },
|
|
2304
|
+
analysis_type: { type: 'string', description: 'Analysis: frequency, readability, diversity, all (default: all)' },
|
|
2305
|
+
},
|
|
2306
|
+
tier: 'free',
|
|
2307
|
+
async execute(args) {
|
|
2308
|
+
const text = String(args.text);
|
|
2309
|
+
const analysisType = (args.analysis_type ? String(args.analysis_type) : 'all').toLowerCase().trim();
|
|
2310
|
+
if (text.trim().length === 0)
|
|
2311
|
+
return '**Error**: No text provided.';
|
|
2312
|
+
const showAll = analysisType === 'all';
|
|
2313
|
+
// Basic tokenization
|
|
2314
|
+
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0);
|
|
2315
|
+
const words = text.toLowerCase().replace(/[^a-z\s'-]/g, ' ').split(/\s+/).filter(w => w.length > 0);
|
|
2316
|
+
const syllableCounts = words.map(countSyllables);
|
|
2317
|
+
const totalSyllables = sum(syllableCounts);
|
|
2318
|
+
const totalWords = words.length;
|
|
2319
|
+
const totalSentences = Math.max(sentences.length, 1);
|
|
2320
|
+
const totalChars = words.join('').length;
|
|
2321
|
+
// Word frequency
|
|
2322
|
+
const freq = new Map();
|
|
2323
|
+
for (const w of words) {
|
|
2324
|
+
freq.set(w, (freq.get(w) ?? 0) + 1);
|
|
2325
|
+
}
|
|
2326
|
+
// Stop words to filter for meaningful frequency analysis
|
|
2327
|
+
const stopWords = new Set([
|
|
2328
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
2329
|
+
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
2330
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
2331
|
+
'could', 'should', 'may', 'might', 'shall', 'can', 'it', 'its',
|
|
2332
|
+
'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our',
|
|
2333
|
+
'you', 'your', 'he', 'she', 'they', 'them', 'their', 'his', 'her',
|
|
2334
|
+
'not', 'no', 'so', 'if', 'as', 'up', 'out', 'about', 'into',
|
|
2335
|
+
'than', 'then', 'also', 'just', 'more', 'very', 'what', 'which',
|
|
2336
|
+
'who', 'how', 'when', 'where', 'there', 'here', 'all', 'each',
|
|
2337
|
+
'both', 'few', 'some', 'any', 'most', 'other', 'such', 'only',
|
|
2338
|
+
]);
|
|
2339
|
+
const lines = [
|
|
2340
|
+
`# Discourse Analysis`,
|
|
2341
|
+
'',
|
|
2342
|
+
`## Text Statistics`,
|
|
2343
|
+
`| Metric | Value |`,
|
|
2344
|
+
`|--------|-------|`,
|
|
2345
|
+
`| Words | ${totalWords} |`,
|
|
2346
|
+
`| Sentences | ${totalSentences} |`,
|
|
2347
|
+
`| Characters (no spaces) | ${totalChars} |`,
|
|
2348
|
+
`| Syllables | ${totalSyllables} |`,
|
|
2349
|
+
`| Unique words | ${freq.size} |`,
|
|
2350
|
+
`| Avg words/sentence | ${fmt(totalWords / totalSentences)} |`,
|
|
2351
|
+
`| Avg syllables/word | ${fmt(totalSyllables / totalWords)} |`,
|
|
2352
|
+
`| Avg word length (chars) | ${fmt(totalChars / totalWords)} |`,
|
|
2353
|
+
'',
|
|
2354
|
+
];
|
|
2355
|
+
// ── Frequency analysis ──
|
|
2356
|
+
if (showAll || analysisType === 'frequency') {
|
|
2357
|
+
// Top 20 words overall
|
|
2358
|
+
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
|
2359
|
+
const top20All = sorted.slice(0, 20);
|
|
2360
|
+
// Top 20 content words (no stop words)
|
|
2361
|
+
const contentWords = sorted.filter(([w]) => !stopWords.has(w));
|
|
2362
|
+
const top20Content = contentWords.slice(0, 20);
|
|
2363
|
+
// TF-IDF (treat each sentence as a document)
|
|
2364
|
+
const sentenceWords = sentences.map(s => s.toLowerCase().replace(/[^a-z\s'-]/g, ' ').split(/\s+/).filter(w => w.length > 0));
|
|
2365
|
+
const df = new Map();
|
|
2366
|
+
for (const sw of sentenceWords) {
|
|
2367
|
+
const unique = new Set(sw);
|
|
2368
|
+
for (const w of unique) {
|
|
2369
|
+
df.set(w, (df.get(w) ?? 0) + 1);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
const tfidf = new Map();
|
|
2373
|
+
for (const [word, tf] of freq) {
|
|
2374
|
+
if (stopWords.has(word))
|
|
2375
|
+
continue;
|
|
2376
|
+
const idf = Math.log(totalSentences / (df.get(word) ?? 1));
|
|
2377
|
+
tfidf.set(word, tf * idf);
|
|
2378
|
+
}
|
|
2379
|
+
const topTfidf = [...tfidf.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
2380
|
+
lines.push(`## Word Frequency`, '', `### Top 20 Words (all)`, `| Rank | Word | Count | Frequency |`, `|------|------|-------|-----------|`, ...top20All.map(([w, c], i) => `| ${i + 1} | ${w} | ${c} | ${fmt(c / totalWords * 100, 2)}% |`), '', `### Top 20 Content Words (no stop words)`, `| Rank | Word | Count | Frequency |`, `|------|------|-------|-----------|`, ...top20Content.map(([w, c], i) => `| ${i + 1} | ${w} | ${c} | ${fmt(c / totalWords * 100, 2)}% |`), '', `### Top TF-IDF Keywords`, `| Word | TF-IDF Score |`, `|------|-------------|`, ...topTfidf.map(([w, s]) => `| ${w} | ${fmt(s)} |`), '');
|
|
2381
|
+
}
|
|
2382
|
+
// ── Readability ──
|
|
2383
|
+
if (showAll || analysisType === 'readability') {
|
|
2384
|
+
const awl = totalWords / totalSentences; // avg words per sentence
|
|
2385
|
+
const asl = totalSyllables / totalWords; // avg syllables per word
|
|
2386
|
+
// Flesch Reading Ease
|
|
2387
|
+
const fleschRE = 206.835 - 1.015 * awl - 84.6 * asl;
|
|
2388
|
+
// Flesch-Kincaid Grade Level
|
|
2389
|
+
const fleschKG = 0.39 * awl + 11.8 * asl - 15.59;
|
|
2390
|
+
// Gunning Fog Index
|
|
2391
|
+
const complexWords = words.filter((_, i) => syllableCounts[i] >= 3).length;
|
|
2392
|
+
const fogIndex = 0.4 * (awl + 100 * complexWords / totalWords);
|
|
2393
|
+
// Coleman-Liau Index
|
|
2394
|
+
const L = (totalChars / totalWords) * 100; // avg letters per 100 words
|
|
2395
|
+
const S = (totalSentences / totalWords) * 100; // avg sentences per 100 words
|
|
2396
|
+
const colemanLiau = 0.0588 * L - 0.296 * S - 15.8;
|
|
2397
|
+
// SMOG Grade
|
|
2398
|
+
const polysyllables = words.filter((_, i) => syllableCounts[i] >= 3).length;
|
|
2399
|
+
const smog = totalSentences >= 3
|
|
2400
|
+
? 1.0430 * Math.sqrt(polysyllables * (30 / totalSentences)) + 3.1291
|
|
2401
|
+
: 0;
|
|
2402
|
+
// Automated Readability Index
|
|
2403
|
+
const ari = 4.71 * (totalChars / totalWords) + 0.5 * (totalWords / totalSentences) - 21.43;
|
|
2404
|
+
function readabilityLevel(grade) {
|
|
2405
|
+
if (grade <= 5)
|
|
2406
|
+
return 'Elementary';
|
|
2407
|
+
if (grade <= 8)
|
|
2408
|
+
return 'Middle school';
|
|
2409
|
+
if (grade <= 12)
|
|
2410
|
+
return 'High school';
|
|
2411
|
+
if (grade <= 16)
|
|
2412
|
+
return 'College';
|
|
2413
|
+
return 'Graduate';
|
|
2414
|
+
}
|
|
2415
|
+
lines.push(`## Readability Scores`, `| Index | Score | Grade Level | Audience |`, `|-------|-------|-------------|----------|`, `| Flesch Reading Ease | ${fmt(fleschRE)} | ${fleschRE > 90 ? '5th grade' : fleschRE > 80 ? '6th grade' : fleschRE > 70 ? '7th grade' : fleschRE > 60 ? '8-9th grade' : fleschRE > 50 ? '10-12th grade' : fleschRE > 30 ? 'College' : 'Graduate'} | ${fleschRE > 60 ? 'General public' : fleschRE > 30 ? 'Educated adults' : 'Specialists'} |`, `| Flesch-Kincaid Grade | ${fmt(fleschKG)} | Grade ${Math.round(fleschKG)} | ${readabilityLevel(fleschKG)} |`, `| Gunning Fog | ${fmt(fogIndex)} | Grade ${Math.round(fogIndex)} | ${readabilityLevel(fogIndex)} |`, `| Coleman-Liau | ${fmt(colemanLiau)} | Grade ${Math.round(colemanLiau)} | ${readabilityLevel(colemanLiau)} |`, `| SMOG | ${fmt(smog)} | Grade ${Math.round(smog)} | ${readabilityLevel(smog)} |`, `| ARI | ${fmt(ari)} | Grade ${Math.round(ari)} | ${readabilityLevel(ari)} |`, '', `**Consensus grade level**: ~${fmt(mean([fleschKG, fogIndex, colemanLiau, smog > 0 ? smog : fleschKG, ari].filter(x => x > 0)))} (${readabilityLevel(mean([fleschKG, fogIndex, colemanLiau].filter(x => x > 0)))})`, '');
|
|
2416
|
+
// Sentence length distribution
|
|
2417
|
+
const sentLengths = sentences.map(s => s.toLowerCase().replace(/[^a-z\s'-]/g, ' ').split(/\s+/).filter(w => w.length > 0).length);
|
|
2418
|
+
const shortSent = sentLengths.filter(l => l <= 10).length;
|
|
2419
|
+
const medSent = sentLengths.filter(l => l > 10 && l <= 20).length;
|
|
2420
|
+
const longSent = sentLengths.filter(l => l > 20 && l <= 35).length;
|
|
2421
|
+
const veryLongSent = sentLengths.filter(l => l > 35).length;
|
|
2422
|
+
lines.push(`### Sentence Length Distribution`, `| Length | Count | Percentage |`, `|--------|-------|------------|`, `| Short (1-10 words) | ${shortSent} | ${fmt(100 * shortSent / totalSentences, 1)}% |`, `| Medium (11-20 words) | ${medSent} | ${fmt(100 * medSent / totalSentences, 1)}% |`, `| Long (21-35 words) | ${longSent} | ${fmt(100 * longSent / totalSentences, 1)}% |`, `| Very long (36+ words) | ${veryLongSent} | ${fmt(100 * veryLongSent / totalSentences, 1)}% |`, '', `- Shortest sentence: ${Math.min(...sentLengths)} words`, `- Longest sentence: ${Math.max(...sentLengths)} words`, `- SD of sentence length: ${fmt(stddev(sentLengths))}`, '');
|
|
2423
|
+
}
|
|
2424
|
+
// ── Vocabulary diversity ──
|
|
2425
|
+
if (showAll || analysisType === 'diversity') {
|
|
2426
|
+
// Type-Token Ratio
|
|
2427
|
+
const ttr = freq.size / totalWords;
|
|
2428
|
+
// Hapax legomena (words appearing exactly once)
|
|
2429
|
+
const hapax = [...freq.values()].filter(c => c === 1).length;
|
|
2430
|
+
const hapaxRatio = hapax / freq.size;
|
|
2431
|
+
// Dis legomena (appearing exactly twice)
|
|
2432
|
+
const dis = [...freq.values()].filter(c => c === 2).length;
|
|
2433
|
+
// Yule's K (measure of vocabulary richness, lower = more diverse)
|
|
2434
|
+
// K = 10^4 * (M2 - N) / N^2 where M2 = sum(i^2 * V_i) and V_i = freq of freq i
|
|
2435
|
+
const freqOfFreq = new Map();
|
|
2436
|
+
for (const c of freq.values()) {
|
|
2437
|
+
freqOfFreq.set(c, (freqOfFreq.get(c) ?? 0) + 1);
|
|
2438
|
+
}
|
|
2439
|
+
let M2 = 0;
|
|
2440
|
+
for (const [i, Vi] of freqOfFreq) {
|
|
2441
|
+
M2 += i * i * Vi;
|
|
2442
|
+
}
|
|
2443
|
+
const yulesK = totalWords > 0 ? 10000 * (M2 - totalWords) / (totalWords * totalWords) : 0;
|
|
2444
|
+
// Simpson's D (probability two randomly chosen words are the same)
|
|
2445
|
+
let simpsonsD = 0;
|
|
2446
|
+
for (const c of freq.values()) {
|
|
2447
|
+
simpsonsD += c * (c - 1);
|
|
2448
|
+
}
|
|
2449
|
+
simpsonsD = totalWords > 1 ? simpsonsD / (totalWords * (totalWords - 1)) : 0;
|
|
2450
|
+
// Brunet's W (W = N^(V^-0.172))
|
|
2451
|
+
const brunetsW = Math.pow(totalWords, Math.pow(freq.size, -0.172));
|
|
2452
|
+
// Honore's R (if hapax > 0): R = 100 * log(N) / (1 - hapax/V)
|
|
2453
|
+
const honoresR = hapax < freq.size
|
|
2454
|
+
? (100 * Math.log(totalWords)) / (1 - hapax / freq.size)
|
|
2455
|
+
: 0;
|
|
2456
|
+
lines.push(`## Vocabulary Diversity`, `| Measure | Value | Interpretation |`, `|---------|-------|----------------|`, `| Type-Token Ratio (TTR) | ${fmt(ttr)} | ${ttr > 0.7 ? 'High diversity' : ttr > 0.5 ? 'Moderate diversity' : 'Low diversity (repetitive)'} |`, `| Hapax legomena | ${hapax} (${fmt(100 * hapaxRatio, 1)}% of types) | Words used only once |`, `| Dis legomena | ${dis} | Words used exactly twice |`, `| Yule's K | ${fmt(yulesK)} | ${yulesK < 100 ? 'High diversity' : yulesK < 150 ? 'Moderate' : 'Low diversity'} (lower = more diverse) |`, `| Simpson's D | ${fmt(simpsonsD)} | ${simpsonsD < 0.01 ? 'High diversity' : simpsonsD < 0.05 ? 'Moderate' : 'Low diversity'} (lower = more diverse) |`, `| Brunet's W | ${fmt(brunetsW)} | Lower = richer vocabulary |`, `| Honore's R | ${fmt(honoresR)} | Higher = richer vocabulary |`, '', `### Frequency Spectrum`, `| Frequency | # Words | Cumulative % of Types |`, `|-----------|---------|----------------------|`);
|
|
2457
|
+
const sortedFoF = [...freqOfFreq.entries()].sort((a, b) => a[0] - b[0]);
|
|
2458
|
+
let cumTypes = 0;
|
|
2459
|
+
for (const [fq, count] of sortedFoF.slice(0, 10)) {
|
|
2460
|
+
cumTypes += count;
|
|
2461
|
+
lines.push(`| ${fq} | ${count} | ${fmt(100 * cumTypes / freq.size, 1)}% |`);
|
|
2462
|
+
}
|
|
2463
|
+
if (sortedFoF.length > 10) {
|
|
2464
|
+
const remaining = sum(sortedFoF.slice(10).map(([_, c]) => c));
|
|
2465
|
+
lines.push(`| 11+ | ${remaining} | 100% |`);
|
|
2466
|
+
}
|
|
2467
|
+
lines.push('');
|
|
2468
|
+
}
|
|
2469
|
+
return lines.join('\n');
|
|
2470
|
+
},
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
// ─── Utility Functions ───────────────────────────────────────────────────────
|
|
2474
|
+
/** Count syllables in a word (English heuristic) */
|
|
2475
|
+
function countSyllables(word) {
|
|
2476
|
+
word = word.toLowerCase().replace(/[^a-z]/g, '');
|
|
2477
|
+
if (word.length <= 2)
|
|
2478
|
+
return 1;
|
|
2479
|
+
// Remove silent e at end
|
|
2480
|
+
word = word.replace(/e$/, '');
|
|
2481
|
+
if (word.length === 0)
|
|
2482
|
+
return 1;
|
|
2483
|
+
// Count vowel groups
|
|
2484
|
+
const vowelGroups = word.match(/[aeiouy]+/g);
|
|
2485
|
+
let count = vowelGroups ? vowelGroups.length : 1;
|
|
2486
|
+
// Adjust for common patterns
|
|
2487
|
+
if (word.match(/le$/))
|
|
2488
|
+
count++;
|
|
2489
|
+
if (word.match(/[^aeiou]y$/))
|
|
2490
|
+
count = Math.max(count, 1);
|
|
2491
|
+
return Math.max(count, 1);
|
|
2492
|
+
}
|
|
2493
|
+
/** Significance stars for p-values */
|
|
2494
|
+
function sigStars(p) {
|
|
2495
|
+
if (p < 0.001)
|
|
2496
|
+
return '***';
|
|
2497
|
+
if (p < 0.01)
|
|
2498
|
+
return '**';
|
|
2499
|
+
if (p < 0.05)
|
|
2500
|
+
return '*';
|
|
2501
|
+
if (p < 0.1)
|
|
2502
|
+
return '.';
|
|
2503
|
+
return '';
|
|
2504
|
+
}
|
|
2505
|
+
/** Deterministic pseudo-random for label propagation reproducibility */
|
|
2506
|
+
function deterministicRandom(seed) {
|
|
2507
|
+
let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
2508
|
+
return x - Math.floor(x);
|
|
2509
|
+
}
|
|
2510
|
+
/** Compute top-k eigenvalues via power iteration + deflation */
|
|
2511
|
+
function computeEigenvalues(matrix, k) {
|
|
2512
|
+
const n = matrix.length;
|
|
2513
|
+
const eigenvalues = [];
|
|
2514
|
+
// Work on a copy
|
|
2515
|
+
const A = matrix.map(row => [...row]);
|
|
2516
|
+
for (let e = 0; e < k; e++) {
|
|
2517
|
+
// Power iteration
|
|
2518
|
+
let vec = new Array(n).fill(0).map((_, i) => (i === e % n) ? 1 : 0.5);
|
|
2519
|
+
let eigenvalue = 0;
|
|
2520
|
+
for (let iter = 0; iter < 200; iter++) {
|
|
2521
|
+
// Multiply A * vec
|
|
2522
|
+
const newVec = new Array(n).fill(0);
|
|
2523
|
+
for (let i = 0; i < n; i++) {
|
|
2524
|
+
for (let j = 0; j < n; j++) {
|
|
2525
|
+
newVec[i] += A[i][j] * vec[j];
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
// Find the eigenvalue (Rayleigh quotient)
|
|
2529
|
+
let dot1 = 0, dot2 = 0;
|
|
2530
|
+
for (let i = 0; i < n; i++) {
|
|
2531
|
+
dot1 += newVec[i] * vec[i];
|
|
2532
|
+
dot2 += vec[i] * vec[i];
|
|
2533
|
+
}
|
|
2534
|
+
const newEigenvalue = dot2 > 0 ? dot1 / dot2 : 0;
|
|
2535
|
+
// Normalize
|
|
2536
|
+
const norm = Math.sqrt(newVec.reduce((s, v) => s + v * v, 0));
|
|
2537
|
+
if (norm > 0) {
|
|
2538
|
+
for (let i = 0; i < n; i++)
|
|
2539
|
+
newVec[i] /= norm;
|
|
2540
|
+
}
|
|
2541
|
+
const diff = Math.abs(newEigenvalue - eigenvalue);
|
|
2542
|
+
eigenvalue = newEigenvalue;
|
|
2543
|
+
vec = newVec;
|
|
2544
|
+
if (diff < 1e-10)
|
|
2545
|
+
break;
|
|
2546
|
+
}
|
|
2547
|
+
eigenvalues.push(eigenvalue);
|
|
2548
|
+
// Deflation: A = A - eigenvalue * v * v^T
|
|
2549
|
+
for (let i = 0; i < n; i++) {
|
|
2550
|
+
for (let j = 0; j < n; j++) {
|
|
2551
|
+
A[i][j] -= eigenvalue * vec[i] * vec[j];
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return eigenvalues;
|
|
2556
|
+
}
|
|
2557
|
+
//# sourceMappingURL=lab-social.js.map
|