@qe-mcp/server-ena 0.1.1

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.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: ena-analysis
3
+ description: >
4
+ Run Epistemic Network Analysis (ENA) on tabular coded data using the
5
+ @qe-mcp/server-ena connector. Use whenever the user references tabular data
6
+ with binary presence/absence codes and asks for analysis, exploration,
7
+ co-occurrence patterns, or group comparison — especially discourse data,
8
+ behavioral coding, learning analytics, or clinical observation data.
9
+ Includes a privacy gate for sensitive datasets: raw data can stay entirely
10
+ on the user's machine.
11
+ ---
12
+
13
+ # ENA Analysis
14
+
15
+ Epistemic Network Analysis models patterns of co-occurrence between coded
16
+ events (discourse moves, behaviors, features) within conversations, projecting
17
+ each unit of analysis into a low-dimensional space where distances are
18
+ interpretable. It reveals structural differences that summary statistics and
19
+ regression miss.
20
+
21
+ This skill drives the **ena** MCP connector (`@qe-mcp/server-ena`). All model
22
+ computation runs locally on the user's machine via WebAssembly — only results
23
+ (node positions, plots, summary statistics) are returned to Claude.
24
+
25
+ ## Privacy gate — always ask first
26
+
27
+ Before calling any ENA tool on new data, ask:
28
+
29
+ > "Is this data sensitive or confidential? If so, give me the file path
30
+ > instead of pasting the data — raw data stays on your machine when you use
31
+ > a file path."
32
+
33
+ If the data is sensitive (or the user hasn't answered yet):
34
+
35
+ 1. Call `ena_profile` with only `data_path`. It returns column names, unique
36
+ counts, and binary detection — **no raw row data is ever transmitted**.
37
+ 2. Use the profile to determine parameters (units, conversations, codes).
38
+ 3. Call `ena_fit` / `ena_compare_groups` with `data_path`. Only model results
39
+ reach the conversation.
40
+
41
+ Note: unit labels (values from the unit column, e.g. participant IDs) appear
42
+ in model results. If those are themselves sensitive, warn the user before
43
+ fitting.
44
+
45
+ If the data is not sensitive, or is toy/demo data, proceed with `ena_inspect`
46
+ directly. Prefer `data_path` over `data_csv` in all cases; use `data_csv`
47
+ only for small pasted samples.
48
+
49
+ ## When ENA fits the data
50
+
51
+ - Rows are utterances, events, or observations
52
+ - Columns include binary (0/1) presence/absence codes
53
+ - A column identifies who produced each row (unit of analysis)
54
+ - A column groups rows into episodes or conversations
55
+ - Optionally: two or more conditions/groups to compare
56
+
57
+ ## Workflow
58
+
59
+ 1. **Triage** — `ena_inspect` (or `ena_profile` for sensitive data). Returns
60
+ `ena_recommended`, suggested codes/units/conversations, and a pre-filled
61
+ `next_step` call. If `ena_recommended` is true, propose ENA as the primary
62
+ analysis and briefly explain what it will reveal.
63
+ 2. **Fit** — `ena_fit` for a single model, or `ena_compare_groups` when two
64
+ groups exist. Both return a `model_id` and an inline network plot.
65
+ Do not generate your own visualization — the plot comes back as an image.
66
+ 3. **Explore** — `ena_plot` with the cached `model_id` renders additional
67
+ views without refitting:
68
+ - `all` — all units
69
+ - `group` — one group's mean network (`group_value`)
70
+ - `comparison` — two group networks overlaid (`group1`, `group2`)
71
+ - `subtraction` — difference network (`group1`, `group2`)
72
+ - `unit` — a single unit's network (`unit_label`)
73
+ 4. **Interpret** — strong connections indicate codes that co-occur within
74
+ the stanza window; in subtraction plots, edge color/direction shows which
75
+ group each connection favors. Node positions are fixed across views of the
76
+ same model, so plots are directly comparable.
77
+
78
+ Each fit/plot response also includes `interactive_plot` — a local HTML file
79
+ path with the interactive visualization the user can open in a browser.
80
+
81
+ Repeated calls with identical parameters return the cached model instantly,
82
+ so iterating on views is cheap.
package/test-mcp.mjs ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * test-mcp.mjs — drive the MCP server over newline-delimited JSON-RPC.
3
+ *
4
+ * Usage:
5
+ * node test-mcp.mjs
6
+ * node test-mcp.mjs /path/to/data.csv UnitCol ConvoCol Code1 Code2 ...
7
+ *
8
+ * PNG images are saved to /tmp/ena-test-N.png and opened automatically.
9
+ */
10
+
11
+ import { spawn } from 'child_process';
12
+ import { writeFileSync } from 'fs';
13
+ import { createInterface } from 'readline';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, join } from 'path';
16
+
17
+ const DIR = dirname(fileURLToPath(import.meta.url));
18
+
19
+ // ── configure ─────────────────────────────────────────────────────────────────
20
+
21
+ const DATA_PATH = process.argv[2] ?? null;
22
+ // UNIT_COL/CONVO_COL accept a single string or comma-separated list for compound columns
23
+ const UNIT_COL = process.argv[3] ?? 'ENA_UNIT';
24
+ const CONVO_COL = process.argv[4] ?? 'Condition';
25
+ const CODES = process.argv.slice(5).length
26
+ ? process.argv.slice(5)
27
+ : ['CodeA', 'CodeB', 'CodeC'];
28
+
29
+ // For rs.data.new.csv group comparison:
30
+ // units=['UserName','Condition'] conversations='GameDay' group_col='Condition'
31
+ // group1='FirstGame' group2='SecondGame'
32
+ const GROUP_COL = DATA_PATH ? 'Condition' : null;
33
+ const GROUP1 = DATA_PATH ? 'FirstGame' : null;
34
+ const GROUP2 = DATA_PATH ? 'SecondGame' : null;
35
+
36
+ // Compound units: each UserName×Condition pair is a separate unit
37
+ const UNIT_COLS = DATA_PATH ? ['UserName', 'Condition'] : UNIT_COL;
38
+ const CONVO_COLS = DATA_PATH ? 'GameDay' : CONVO_COL;
39
+
40
+ const INLINE_CSV = `ENA_UNIT,Condition,CodeA,CodeB,CodeC
41
+ u1,A,1,0,1
42
+ u1,A,0,1,1
43
+ u2,A,1,1,0
44
+ u2,A,0,0,1
45
+ u3,B,1,1,0
46
+ u3,B,1,0,1
47
+ u4,B,0,1,1
48
+ u4,B,1,1,0`;
49
+
50
+ // ── transport (newline-delimited JSON, as used by SDK v1.4) ───────────────────
51
+
52
+ const proc = spawn('node', [join(DIR, 'server.js')], {
53
+ stdio: ['pipe', 'pipe', 'inherit'],
54
+ });
55
+
56
+ let _seq = 0;
57
+ const _pending = new Map();
58
+
59
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
60
+ rl.on('line', line => {
61
+ if (!line.trim()) return;
62
+ let msg;
63
+ try { msg = JSON.parse(line); } catch { return; }
64
+ const cb = _pending.get(msg.id);
65
+ if (cb) { _pending.delete(msg.id); cb(msg); }
66
+ });
67
+
68
+ proc.on('close', () => {
69
+ for (const cb of _pending.values()) cb({ error: { message: 'server closed' } });
70
+ });
71
+
72
+ function rpc(method, params) {
73
+ const id = ++_seq;
74
+ return new Promise(resolve => {
75
+ _pending.set(id, resolve);
76
+ proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
77
+ });
78
+ }
79
+
80
+ // ── result handler ────────────────────────────────────────────────────────────
81
+
82
+ let imgCount = 0;
83
+
84
+ function handleResult(label, msg) {
85
+ if (msg.error) {
86
+ console.log(`[${label}] ✗ ${msg.error.message ?? JSON.stringify(msg.error)}`);
87
+ return null;
88
+ }
89
+ const content = msg.result?.content ?? [];
90
+ let parsed = null;
91
+ for (const block of content) {
92
+ if (block.type === 'image') {
93
+ const path = `/tmp/ena-test-${++imgCount}.png`;
94
+ writeFileSync(path, Buffer.from(block.data, 'base64'));
95
+ console.log(`[${label}] ✓ image → ${path}`);
96
+ }
97
+ if (block.type === 'text') {
98
+ try { parsed = JSON.parse(block.text); } catch { parsed = block.text; }
99
+ if (parsed?._render_error) console.log(` render error: ${parsed._render_error}`);
100
+ if (parsed?.interactive_plot) console.log(` html → ${parsed.interactive_plot}`);
101
+ }
102
+ }
103
+ return parsed;
104
+ }
105
+
106
+ // ── test sequence ─────────────────────────────────────────────────────────────
107
+
108
+ async function run() {
109
+ // Handshake
110
+ const initMsg = await rpc('initialize', {
111
+ protocolVersion: '2024-11-05',
112
+ capabilities: {},
113
+ clientInfo: { name: 'test-mcp', version: '1.0' },
114
+ });
115
+ if (initMsg.error) throw new Error('initialize failed: ' + initMsg.error.message);
116
+ // Required: send initialized notification (no response)
117
+ proc.stdin.write(JSON.stringify({ jsonrpc:'2.0', method:'notifications/initialized' }) + '\n');
118
+
119
+ // List tools
120
+ const listMsg = await rpc('tools/list', {});
121
+ const tools = listMsg.result?.tools ?? [];
122
+ console.log(`tools: ${tools.map(t => t.name).join(', ')}\n`);
123
+
124
+ const data = DATA_PATH ? { data_path: DATA_PATH } : { data_csv: INLINE_CSV };
125
+
126
+ // ena_profile (privacy-safe metadata only — only works with data_path)
127
+ if (DATA_PATH) {
128
+ const profile = handleResult('ena_profile',
129
+ await rpc('tools/call', { name: 'ena_profile', arguments: { data_path: DATA_PATH } }));
130
+ if (profile) {
131
+ console.log(` ${profile.privacy_note}`);
132
+ console.log(` binary cols: ${profile.binary_columns?.join(', ')}`);
133
+ }
134
+ }
135
+
136
+ // ena_inspect
137
+ const inspect = handleResult('ena_inspect',
138
+ await rpc('tools/call', { name: 'ena_inspect', arguments: data }));
139
+ if (inspect) {
140
+ console.log(` recommended=${inspect.ena_recommended} codes: ${inspect.suggested_codes?.join(', ')}`);
141
+ if (inspect.privacy_note) console.log(` ${inspect.privacy_note}`);
142
+ }
143
+
144
+ // ena_fit
145
+ const fitArgs = {
146
+ ...data,
147
+ units: UNIT_COLS, conversations: CONVO_COLS, codes: CODES,
148
+ window_size: 4, binary: true, dims: 2, rotation: 'svd',
149
+ ...(GROUP_COL ? { group_col: GROUP_COL } : {}),
150
+ };
151
+ const fit = handleResult('ena_fit',
152
+ await rpc('tools/call', { name: 'ena_fit', arguments: fitArgs }));
153
+ if (!fit) { proc.kill(); return; }
154
+ console.log(` model_id=${fit.model_id} units=${fit.n_units} connections=${fit.n_connections}`);
155
+
156
+ const modelId = fit.model_id;
157
+
158
+ // ena_plot — all units
159
+ handleResult('ena_plot(all)',
160
+ await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: modelId, type: 'all' } }));
161
+
162
+ // ena_plot — first unit
163
+ const firstUnit = fit.units?.[0];
164
+ if (firstUnit) {
165
+ handleResult(`ena_plot(unit=${firstUnit})`,
166
+ await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: modelId, type: 'unit', unit_label: firstUnit } }));
167
+ }
168
+
169
+ // ena_compare_groups + subtraction (if GROUP_COL is set)
170
+ if (GROUP_COL && GROUP1 && GROUP2) {
171
+ const cmpArgs = { ...fitArgs, rotation: undefined, group_col: GROUP_COL, group1_value: GROUP1, group2_value: GROUP2 };
172
+ delete cmpArgs.rotation;
173
+ const cmp = handleResult('ena_compare_groups',
174
+ await rpc('tools/call', { name: 'ena_compare_groups', arguments: cmpArgs }));
175
+ if (cmp) {
176
+ console.log(` model_id=${cmp.model_id} g1=${JSON.stringify(cmp.group1_mean)} g2=${JSON.stringify(cmp.group2_mean)}`);
177
+ handleResult('ena_plot(subtraction)',
178
+ await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: cmp.model_id, type: 'subtraction', group1: GROUP1, group2: GROUP2 } }));
179
+ handleResult('ena_plot(comparison)',
180
+ await rpc('tools/call', { name: 'ena_plot', arguments: { model_id: cmp.model_id, type: 'comparison', group1: GROUP1, group2: GROUP2 } }));
181
+ }
182
+ }
183
+
184
+ proc.stdin.end();
185
+ console.log(`\ndone — ${imgCount} image(s) saved`);
186
+ if (imgCount) console.log('open: open /tmp/ena-test-*.png');
187
+ }
188
+
189
+ run().catch(err => { console.error(err.message); proc.kill(); });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Smoke test: load WASM, fit a model, render to PNG.
3
+ * Run with: node test-render.mjs
4
+ */
5
+ import loadENA from '@qe-libs/rena-wasm';
6
+ import { readFileSync, writeFileSync } from 'fs';
7
+ import { createRequire } from 'module';
8
+ import { JSDOM } from 'jsdom';
9
+ import { Resvg } from '@resvg/resvg-js';
10
+
11
+ const _require = createRequire(import.meta.url);
12
+ const QEVIZ_BUNDLE = _require.resolve('@qe-libs/qeviz');
13
+ const qevizJS = () => readFileSync(QEVIZ_BUNDLE, 'utf8');
14
+ const SVG_SIZE = 600;
15
+
16
+ // Minimal synthetic dataset
17
+ const rows = [
18
+ { unit: 'A', convo: 'c1', X: '1', Y: '0', Z: '1' },
19
+ { unit: 'A', convo: 'c1', X: '0', Y: '1', Z: '1' },
20
+ { unit: 'B', convo: 'c1', X: '1', Y: '1', Z: '0' },
21
+ { unit: 'B', convo: 'c1', X: '0', Y: '0', Z: '1' },
22
+ { unit: 'C', convo: 'c2', X: '1', Y: '1', Z: '0' },
23
+ { unit: 'C', convo: 'c2', X: '1', Y: '0', Z: '1' },
24
+ ];
25
+ const codes = ['X', 'Y', 'Z'];
26
+
27
+ console.log('1. Loading WASM...');
28
+ const ena = await loadENA();
29
+ console.log(' OK');
30
+
31
+ console.log('2. Fitting model...');
32
+ const model = ena.fit(rows, {
33
+ codes,
34
+ units: ['unit'],
35
+ conversations: ['convo'],
36
+ window: 4,
37
+ binary: true,
38
+ dims: 2,
39
+ rotation: 'svd',
40
+ });
41
+ console.log(` OK — ${model.nUnits} units, ${model.nConnections} connections`);
42
+ console.log(` centroids type: ${model.centroids?.constructor?.name}`);
43
+ console.log(` positions type: ${model.positions?.constructor?.name}`);
44
+ console.log(` networks type: ${model.networks?.constructor?.name}`);
45
+
46
+ console.log('3. Building vizData...');
47
+ const edgeColName = (n) => n.replace(' & ', '.');
48
+ const modelData = {
49
+ directed: false, updated: Date.now(),
50
+ id_col: 'ENA_UNIT', node_id_col: 'code', x_col: 'x', y_col: 'y',
51
+ nodes: {
52
+ data: codes.map((code, i) => ({ code, x: model.positions[i*2], y: model.positions[i*2+1] })),
53
+ types: { code: 'character', x: 'numeric', y: 'numeric' },
54
+ },
55
+ edges: {
56
+ data: model.unitLabels.map((lbl, i) => {
57
+ const row = { ENA_UNIT: lbl };
58
+ model.connectionNames.forEach((n, j) => { row[edgeColName(n)] = model.networks[i * model.nConnections + j]; });
59
+ return row;
60
+ }),
61
+ types: Object.fromEntries([['ENA_UNIT','character'], ...model.connectionNames.map(n => [edgeColName(n),'numeric'])]),
62
+ },
63
+ points: {
64
+ data: model.unitLabels.map((lbl, i) => ({ ENA_UNIT: lbl, x: model.centroids[i*2], y: model.centroids[i*2+1] })),
65
+ types: { ENA_UNIT: 'character', x: 'numeric', y: 'numeric' },
66
+ },
67
+ };
68
+ console.log(' OK');
69
+
70
+ console.log('4. Rendering SVG via jsdom...');
71
+ const dom = new JSDOM(
72
+ `<!DOCTYPE html><body><qe-visual id="vis"><qe-graph id="g" width="${SVG_SIZE}" height="${SVG_SIZE}" label-nodes="on"><qe-points label="auto"></qe-points></qe-graph></qe-visual></body>`,
73
+ { runScripts: 'dangerously', pretendToBeVisual: true }
74
+ );
75
+ const { window } = dom;
76
+ window.Element.prototype.getBoundingClientRect = () =>
77
+ ({ x:0, y:0, width:SVG_SIZE, height:SVG_SIZE, top:0, left:0, right:SVG_SIZE, bottom:SVG_SIZE });
78
+ window.requestAnimationFrame = cb => setTimeout(cb, 0);
79
+ window.console = { log: ()=>{}, info: ()=>{}, warn: ()=>{}, error: ()=>{}, debug: ()=>{} };
80
+ const s = window.document.createElement('script');
81
+ s.textContent = qevizJS();
82
+ window.document.head.appendChild(s);
83
+ window.document.getElementById('vis').setModelData(modelData);
84
+ await new Promise(r => setTimeout(r, 300));
85
+ const svg = window.document.getElementById('g')?.svg_element?.outerHTML ?? null;
86
+ if (!svg) { console.error(' FAIL — svg_element is null'); process.exit(1); }
87
+ console.log(` OK — SVG ${svg.length} bytes`);
88
+
89
+ console.log('5. Rasterizing to PNG via resvg...');
90
+ const png = new Resvg(svg, { width: SVG_SIZE, height: SVG_SIZE }).render().asPng();
91
+ writeFileSync('/tmp/ena-smoke.png', png);
92
+ console.log(` OK — PNG ${png.length} bytes → /tmp/ena-smoke.png`);
93
+ console.log('\nAll steps passed. Open /tmp/ena-smoke.png to check the visual.');