cyborg-hunter 0.5.0 → 0.7.2
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/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -11,15 +11,21 @@ export async function renderTriage(triage, config) {
|
|
|
11
11
|
'',
|
|
12
12
|
`_${triage.length} participants analyzed_`,
|
|
13
13
|
'',
|
|
14
|
-
'
|
|
15
|
-
'
|
|
14
|
+
'**Tier** is the library\'s two-tier screening verdict: `HARD` = a hard signal',
|
|
15
|
+
'(paste/drop/copy) crossed its count threshold; `soft` = library soft score ≥',
|
|
16
|
+
'its threshold; `clean` = neither. **Score** is the CLI\'s separate ranking',
|
|
17
|
+
'heuristic (5×paste + 5×copy + 3×sidebar + 1×tab-away) — it orders rows',
|
|
18
|
+
'*within* a tier and is not the library soft score.',
|
|
19
|
+
'',
|
|
20
|
+
'| Rank | Participant | Tier | Score | Reason |',
|
|
21
|
+
'|------|-------------|------|-------|--------|',
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
triage.forEach((t, i) => {
|
|
19
|
-
const
|
|
25
|
+
const tier = t.hardTriggered ? '**HARD**' : t.softFlagged ? 'soft' : 'clean';
|
|
20
26
|
// Escape pipe characters in reason text to avoid breaking the table
|
|
21
27
|
const reason = t.reason.replace(/\|/g, '\\|');
|
|
22
|
-
lines.push(`| ${i + 1} | ${t.participantId} | ${
|
|
28
|
+
lines.push(`| ${i + 1} | ${t.participantId} | ${tier} | ${t.score} | ${reason} |`);
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
lines.push('');
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// src/cli/renderers/typing-profile-core.js
|
|
2
|
+
//
|
|
3
|
+
// Pure drawing core for per-participant typing-speed bar-chart images — no
|
|
4
|
+
// Node APIs, so a browser demo can bundle it directly (0.7.2-style
|
|
5
|
+
// extraction from cli/renderers/typing-profile.js, which is now a thin fs
|
|
6
|
+
// wrapper around this module: it acquires node-canvas, calls
|
|
7
|
+
// drawTypingProfile per participant, and writes the returned canvas to a
|
|
8
|
+
// PNG). Mirrors the session-timeline-core.js / trajectories-core.js split.
|
|
9
|
+
//
|
|
10
|
+
// drawTypingProfile(p, config, createCanvas) draws one participant's typing
|
|
11
|
+
// speed bar chart — one bar per trial's chars/sec, with a horizontal
|
|
12
|
+
// threshold line and bars above threshold colored red — and returns the
|
|
13
|
+
// drawn canvas (or null if there's nothing to draw: no trials, or no trial
|
|
14
|
+
// has either a typed speed or a paste event). createCanvas is injected so
|
|
15
|
+
// this module never imports the `canvas` package directly — a browser
|
|
16
|
+
// caller can pass a document-canvas-backed factory instead.
|
|
17
|
+
|
|
18
|
+
const BAR_WIDTH = 30;
|
|
19
|
+
const BAR_GAP = 8;
|
|
20
|
+
const CHART_HEIGHT = 200;
|
|
21
|
+
const TOP_PAD = 50;
|
|
22
|
+
const BOTTOM_PAD = 50;
|
|
23
|
+
const LEFT_PAD = 60;
|
|
24
|
+
|
|
25
|
+
// ── Per-participant renderer ─────────────────────────────────────────────
|
|
26
|
+
// Returns the drawn canvas, or null if there's no data to draw (caller
|
|
27
|
+
// decides what "no data" means — the fs wrapper skips the PNG write).
|
|
28
|
+
export function drawTypingProfile(p, config, createCanvas) {
|
|
29
|
+
const trials = p.trials;
|
|
30
|
+
if (trials.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
// Per-participant threshold line: prefer the typing-speed cutoff the library
|
|
33
|
+
// actually screened this participant with (saved in session.config.thresholds)
|
|
34
|
+
// so the red "fast typing" line matches summary.csv's count; fall back to the
|
|
35
|
+
// CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
|
|
36
|
+
const threshold =
|
|
37
|
+
p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
|
|
38
|
+
|
|
39
|
+
// Per-trial state. We distinguish three cases:
|
|
40
|
+
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
41
|
+
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
|
42
|
+
// grey hatched marker so the absence is legible. Single
|
|
43
|
+
// paste fires one input event → editTimestamps.length < 2
|
|
44
|
+
// → computeTypingSpeed returns null. Without this, a
|
|
45
|
+
// pasted response looks identical to a very slow one.
|
|
46
|
+
// "no-data" — neither, leave blank
|
|
47
|
+
const kinds = trials.map(t => {
|
|
48
|
+
if (typeof t.charsPerSec === 'number') return 'typed';
|
|
49
|
+
if ((t.pasteEvents || []).length > 0) return 'paste-only';
|
|
50
|
+
return 'no-data';
|
|
51
|
+
});
|
|
52
|
+
const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
|
|
53
|
+
if (kinds.every(k => k === 'no-data')) return null;
|
|
54
|
+
|
|
55
|
+
const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
|
|
56
|
+
const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
|
|
57
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
58
|
+
const ctx = canvas.getContext('2d');
|
|
59
|
+
|
|
60
|
+
// Background
|
|
61
|
+
ctx.fillStyle = '#ffffff';
|
|
62
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
63
|
+
|
|
64
|
+
// Title
|
|
65
|
+
ctx.fillStyle = '#333';
|
|
66
|
+
ctx.font = 'bold 14px sans-serif';
|
|
67
|
+
ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
|
|
68
|
+
ctx.font = '10px sans-serif';
|
|
69
|
+
ctx.fillStyle = '#666';
|
|
70
|
+
ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
|
|
71
|
+
|
|
72
|
+
// Legend — three states documented in the `kinds` comment above.
|
|
73
|
+
const legendY = 35;
|
|
74
|
+
const legendX0 = LEFT_PAD + 180;
|
|
75
|
+
ctx.fillStyle = '#2196F3';
|
|
76
|
+
ctx.fillRect(legendX0, legendY - 8, 12, 10);
|
|
77
|
+
ctx.fillStyle = '#666';
|
|
78
|
+
ctx.fillText('typed', legendX0 + 16, legendY);
|
|
79
|
+
ctx.fillStyle = '#f44336';
|
|
80
|
+
ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
|
|
81
|
+
ctx.fillStyle = '#666';
|
|
82
|
+
ctx.fillText('> threshold', legendX0 + 76, legendY);
|
|
83
|
+
// Hatched swatch for paste-only
|
|
84
|
+
drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
|
|
85
|
+
ctx.fillStyle = '#666';
|
|
86
|
+
ctx.fillText('paste (P)', legendX0 + 166, legendY);
|
|
87
|
+
|
|
88
|
+
// Y-axis scaling
|
|
89
|
+
const maxSpeed = Math.max(...speeds, threshold * 1.5);
|
|
90
|
+
const yScale = CHART_HEIGHT / maxSpeed;
|
|
91
|
+
|
|
92
|
+
// Y-axis labels
|
|
93
|
+
ctx.fillStyle = '#666';
|
|
94
|
+
ctx.font = '9px sans-serif';
|
|
95
|
+
const yTicks = 5;
|
|
96
|
+
for (let t = 0; t <= yTicks; t++) {
|
|
97
|
+
const val = (maxSpeed / yTicks) * t;
|
|
98
|
+
const y = TOP_PAD + CHART_HEIGHT - val * yScale;
|
|
99
|
+
ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
|
|
100
|
+
// Grid line
|
|
101
|
+
ctx.strokeStyle = '#eee';
|
|
102
|
+
ctx.lineWidth = 0.5;
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(LEFT_PAD, y);
|
|
105
|
+
ctx.lineTo(canvasW - 10, y);
|
|
106
|
+
ctx.stroke();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Threshold line
|
|
110
|
+
const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
|
|
111
|
+
ctx.strokeStyle = '#ff0000';
|
|
112
|
+
ctx.lineWidth = 1.5;
|
|
113
|
+
ctx.setLineDash([5, 3]);
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
ctx.moveTo(LEFT_PAD, threshY);
|
|
116
|
+
ctx.lineTo(canvasW - 10, threshY);
|
|
117
|
+
ctx.stroke();
|
|
118
|
+
ctx.setLineDash([]);
|
|
119
|
+
|
|
120
|
+
// Bars
|
|
121
|
+
const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
|
|
122
|
+
for (let i = 0; i < trials.length; i++) {
|
|
123
|
+
const kind = kinds[i];
|
|
124
|
+
const speed = speeds[i];
|
|
125
|
+
const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
|
|
126
|
+
|
|
127
|
+
if (kind === 'paste-only') {
|
|
128
|
+
// Fixed-height hatched grey bar + "P" marker. The height is arbitrary
|
|
129
|
+
// — the point is visible presence, not magnitude (we have no CPS to
|
|
130
|
+
// show). Sits at the bottom like a normal bar.
|
|
131
|
+
const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
|
|
132
|
+
drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
133
|
+
ctx.strokeStyle = '#888';
|
|
134
|
+
ctx.lineWidth = 0.5;
|
|
135
|
+
ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
136
|
+
// "P" marker above the bar
|
|
137
|
+
ctx.fillStyle = '#555';
|
|
138
|
+
ctx.font = 'bold 11px sans-serif';
|
|
139
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
140
|
+
} else if (kind === 'typed') {
|
|
141
|
+
const barH = speed * yScale;
|
|
142
|
+
const y = TOP_PAD + CHART_HEIGHT - barH;
|
|
143
|
+
ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
|
|
144
|
+
ctx.fillRect(x, y, BAR_WIDTH, barH);
|
|
145
|
+
ctx.strokeStyle = '#333';
|
|
146
|
+
ctx.lineWidth = 0.5;
|
|
147
|
+
ctx.strokeRect(x, y, BAR_WIDTH, barH);
|
|
148
|
+
if (speed > 0) {
|
|
149
|
+
ctx.fillStyle = '#fff';
|
|
150
|
+
ctx.font = 'bold 9px sans-serif';
|
|
151
|
+
ctx.fillText(speed.toFixed(1), x + 3, y + 12);
|
|
152
|
+
}
|
|
153
|
+
// Mixed: typed AND pasted in the same trial. The bar shows the typing
|
|
154
|
+
// rate (legitimate signal); the "P" glyph above it flags that *some*
|
|
155
|
+
// of the response came in via paste. Distinct from pure paste-only
|
|
156
|
+
// (which is a hatched bar, no colored fill).
|
|
157
|
+
if ((trials[i].pasteEvents || []).length > 0) {
|
|
158
|
+
ctx.fillStyle = '#555';
|
|
159
|
+
ctx.font = 'bold 11px sans-serif';
|
|
160
|
+
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 'no-data' → render nothing (blank column)
|
|
164
|
+
|
|
165
|
+
// Trial label (always drawn, even for blank columns)
|
|
166
|
+
const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
|
|
167
|
+
ctx.fillStyle = '#333';
|
|
168
|
+
ctx.font = '8px sans-serif';
|
|
169
|
+
ctx.save();
|
|
170
|
+
ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
|
|
171
|
+
ctx.rotate(Math.PI / 4);
|
|
172
|
+
ctx.fillText(trialId.substring(0, 12), 0, 0);
|
|
173
|
+
ctx.restore();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Baseline
|
|
177
|
+
ctx.strokeStyle = '#333';
|
|
178
|
+
ctx.lineWidth = 1;
|
|
179
|
+
ctx.beginPath();
|
|
180
|
+
ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
|
|
181
|
+
ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
|
|
182
|
+
ctx.stroke();
|
|
183
|
+
|
|
184
|
+
return canvas;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Cross-hatched fill for paste-only markers. Uses a light grey background
|
|
188
|
+
// with diagonal strokes so the bar reads as "distinct category, not zero".
|
|
189
|
+
function drawHatchedRect(ctx, x, y, w, h) {
|
|
190
|
+
ctx.save();
|
|
191
|
+
ctx.fillStyle = '#e8e8e8';
|
|
192
|
+
ctx.fillRect(x, y, w, h);
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
ctx.rect(x, y, w, h);
|
|
195
|
+
ctx.clip();
|
|
196
|
+
ctx.strokeStyle = '#888';
|
|
197
|
+
ctx.lineWidth = 0.8;
|
|
198
|
+
const step = 5;
|
|
199
|
+
for (let d = -h; d < w + h; d += step) {
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.moveTo(x + d, y);
|
|
202
|
+
ctx.lineTo(x + d + h, y + h);
|
|
203
|
+
ctx.stroke();
|
|
204
|
+
}
|
|
205
|
+
ctx.restore();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Used by the fs wrapper to build the per-participant PNG filename.
|
|
209
|
+
export function sanitize(name) {
|
|
210
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
211
|
+
}
|
|
@@ -1,171 +1,26 @@
|
|
|
1
1
|
// src/cli/renderers/typing-profile.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// Thin fs wrapper around the pure drawing core (typing-profile-core.js).
|
|
3
|
+
// The core takes an injected createCanvas and returns the drawn canvas (or
|
|
4
|
+
// null when there's nothing to draw); this wrapper owns node-canvas
|
|
5
|
+
// acquisition and PNG serialization so the core can also run in a browser
|
|
6
|
+
// context (demo plot adapter) with document canvases.
|
|
6
7
|
import { writeFileSync } from 'fs';
|
|
7
8
|
import { join } from 'path';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
import { drawTypingProfile, sanitize } from './typing-profile-core.js';
|
|
10
|
+
// Load-bearing re-export: drawTypingProfile serves this wrapper's own loop
|
|
11
|
+
// below and the future browser demo plot adapter — mirrors
|
|
12
|
+
// session-timeline.js / trajectories.js. (renderTypingProfiles remains the
|
|
13
|
+
// only symbol external consumers import from this file — see
|
|
14
|
+
// tests/cli/renderers.test.js and src/cli/report.js.)
|
|
15
|
+
export { drawTypingProfile };
|
|
16
|
+
|
|
17
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
16
18
|
export async function renderTypingProfiles(participants, config) {
|
|
17
19
|
const { createCanvas } = await import('canvas');
|
|
18
|
-
const threshold = config.typingSpeedThreshold_cps || 10;
|
|
19
20
|
|
|
20
21
|
for (const p of participants) {
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
// Per-trial state. We distinguish three cases:
|
|
25
|
-
// "typed" — charsPerSec is a real number, draw a normal bar
|
|
26
|
-
// "paste-only" — charsPerSec is null but there's a paste event; draw a
|
|
27
|
-
// grey hatched marker so the absence is legible. Single
|
|
28
|
-
// paste fires one input event → editTimestamps.length < 2
|
|
29
|
-
// → computeTypingSpeed returns null. Without this, a
|
|
30
|
-
// pasted response looks identical to a very slow one.
|
|
31
|
-
// "no-data" — neither, leave blank
|
|
32
|
-
const kinds = trials.map(t => {
|
|
33
|
-
if (typeof t.charsPerSec === 'number') return 'typed';
|
|
34
|
-
if ((t.pasteEvents || []).length > 0) return 'paste-only';
|
|
35
|
-
return 'no-data';
|
|
36
|
-
});
|
|
37
|
-
const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
|
|
38
|
-
if (kinds.every(k => k === 'no-data')) continue;
|
|
39
|
-
|
|
40
|
-
const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
|
|
41
|
-
const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
|
|
42
|
-
const canvas = createCanvas(canvasW, canvasH);
|
|
43
|
-
const ctx = canvas.getContext('2d');
|
|
44
|
-
|
|
45
|
-
// Background
|
|
46
|
-
ctx.fillStyle = '#ffffff';
|
|
47
|
-
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
48
|
-
|
|
49
|
-
// Title
|
|
50
|
-
ctx.fillStyle = '#333';
|
|
51
|
-
ctx.font = 'bold 14px sans-serif';
|
|
52
|
-
ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
|
|
53
|
-
ctx.font = '10px sans-serif';
|
|
54
|
-
ctx.fillStyle = '#666';
|
|
55
|
-
ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
|
|
56
|
-
|
|
57
|
-
// Legend — three states documented in the `kinds` comment above.
|
|
58
|
-
const legendY = 35;
|
|
59
|
-
const legendX0 = LEFT_PAD + 180;
|
|
60
|
-
ctx.fillStyle = '#2196F3';
|
|
61
|
-
ctx.fillRect(legendX0, legendY - 8, 12, 10);
|
|
62
|
-
ctx.fillStyle = '#666';
|
|
63
|
-
ctx.fillText('typed', legendX0 + 16, legendY);
|
|
64
|
-
ctx.fillStyle = '#f44336';
|
|
65
|
-
ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
|
|
66
|
-
ctx.fillStyle = '#666';
|
|
67
|
-
ctx.fillText('> threshold', legendX0 + 76, legendY);
|
|
68
|
-
// Hatched swatch for paste-only
|
|
69
|
-
drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
|
|
70
|
-
ctx.fillStyle = '#666';
|
|
71
|
-
ctx.fillText('paste (P)', legendX0 + 166, legendY);
|
|
72
|
-
|
|
73
|
-
// Y-axis scaling
|
|
74
|
-
const maxSpeed = Math.max(...speeds, threshold * 1.5);
|
|
75
|
-
const yScale = CHART_HEIGHT / maxSpeed;
|
|
76
|
-
|
|
77
|
-
// Y-axis labels
|
|
78
|
-
ctx.fillStyle = '#666';
|
|
79
|
-
ctx.font = '9px sans-serif';
|
|
80
|
-
const yTicks = 5;
|
|
81
|
-
for (let t = 0; t <= yTicks; t++) {
|
|
82
|
-
const val = (maxSpeed / yTicks) * t;
|
|
83
|
-
const y = TOP_PAD + CHART_HEIGHT - val * yScale;
|
|
84
|
-
ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
|
|
85
|
-
// Grid line
|
|
86
|
-
ctx.strokeStyle = '#eee';
|
|
87
|
-
ctx.lineWidth = 0.5;
|
|
88
|
-
ctx.beginPath();
|
|
89
|
-
ctx.moveTo(LEFT_PAD, y);
|
|
90
|
-
ctx.lineTo(canvasW - 10, y);
|
|
91
|
-
ctx.stroke();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Threshold line
|
|
95
|
-
const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
|
|
96
|
-
ctx.strokeStyle = '#ff0000';
|
|
97
|
-
ctx.lineWidth = 1.5;
|
|
98
|
-
ctx.setLineDash([5, 3]);
|
|
99
|
-
ctx.beginPath();
|
|
100
|
-
ctx.moveTo(LEFT_PAD, threshY);
|
|
101
|
-
ctx.lineTo(canvasW - 10, threshY);
|
|
102
|
-
ctx.stroke();
|
|
103
|
-
ctx.setLineDash([]);
|
|
104
|
-
|
|
105
|
-
// Bars
|
|
106
|
-
const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
|
|
107
|
-
for (let i = 0; i < trials.length; i++) {
|
|
108
|
-
const kind = kinds[i];
|
|
109
|
-
const speed = speeds[i];
|
|
110
|
-
const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
|
|
111
|
-
|
|
112
|
-
if (kind === 'paste-only') {
|
|
113
|
-
// Fixed-height hatched grey bar + "P" marker. The height is arbitrary
|
|
114
|
-
// — the point is visible presence, not magnitude (we have no CPS to
|
|
115
|
-
// show). Sits at the bottom like a normal bar.
|
|
116
|
-
const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
|
|
117
|
-
drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
118
|
-
ctx.strokeStyle = '#888';
|
|
119
|
-
ctx.lineWidth = 0.5;
|
|
120
|
-
ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
|
|
121
|
-
// "P" marker above the bar
|
|
122
|
-
ctx.fillStyle = '#555';
|
|
123
|
-
ctx.font = 'bold 11px sans-serif';
|
|
124
|
-
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
125
|
-
} else if (kind === 'typed') {
|
|
126
|
-
const barH = speed * yScale;
|
|
127
|
-
const y = TOP_PAD + CHART_HEIGHT - barH;
|
|
128
|
-
ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
|
|
129
|
-
ctx.fillRect(x, y, BAR_WIDTH, barH);
|
|
130
|
-
ctx.strokeStyle = '#333';
|
|
131
|
-
ctx.lineWidth = 0.5;
|
|
132
|
-
ctx.strokeRect(x, y, BAR_WIDTH, barH);
|
|
133
|
-
if (speed > 0) {
|
|
134
|
-
ctx.fillStyle = '#fff';
|
|
135
|
-
ctx.font = 'bold 9px sans-serif';
|
|
136
|
-
ctx.fillText(speed.toFixed(1), x + 3, y + 12);
|
|
137
|
-
}
|
|
138
|
-
// Mixed: typed AND pasted in the same trial. The bar shows the typing
|
|
139
|
-
// rate (legitimate signal); the "P" glyph above it flags that *some*
|
|
140
|
-
// of the response came in via paste. Distinct from pure paste-only
|
|
141
|
-
// (which is a hatched bar, no colored fill).
|
|
142
|
-
if ((trials[i].pasteEvents || []).length > 0) {
|
|
143
|
-
ctx.fillStyle = '#555';
|
|
144
|
-
ctx.font = 'bold 11px sans-serif';
|
|
145
|
-
ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// 'no-data' → render nothing (blank column)
|
|
149
|
-
|
|
150
|
-
// Trial label (always drawn, even for blank columns)
|
|
151
|
-
const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
|
|
152
|
-
ctx.fillStyle = '#333';
|
|
153
|
-
ctx.font = '8px sans-serif';
|
|
154
|
-
ctx.save();
|
|
155
|
-
ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
|
|
156
|
-
ctx.rotate(Math.PI / 4);
|
|
157
|
-
ctx.fillText(trialId.substring(0, 12), 0, 0);
|
|
158
|
-
ctx.restore();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Baseline
|
|
162
|
-
ctx.strokeStyle = '#333';
|
|
163
|
-
ctx.lineWidth = 1;
|
|
164
|
-
ctx.beginPath();
|
|
165
|
-
ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
|
|
166
|
-
ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
|
|
167
|
-
ctx.stroke();
|
|
168
|
-
|
|
22
|
+
const canvas = drawTypingProfile(p, config, createCanvas);
|
|
23
|
+
if (!canvas) continue;
|
|
169
24
|
const buf = canvas.toBuffer('image/png');
|
|
170
25
|
const filename = `typing_profile_${sanitize(p.participantId)}.png`;
|
|
171
26
|
writeFileSync(join(config.outputDir, 'images', filename), buf);
|
|
@@ -173,28 +28,3 @@ export async function renderTypingProfiles(participants, config) {
|
|
|
173
28
|
|
|
174
29
|
console.log(` typing profiles — rendered`);
|
|
175
30
|
}
|
|
176
|
-
|
|
177
|
-
function sanitize(name) {
|
|
178
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Cross-hatched fill for paste-only markers. Uses a light grey background
|
|
182
|
-
// with diagonal strokes so the bar reads as "distinct category, not zero".
|
|
183
|
-
function drawHatchedRect(ctx, x, y, w, h) {
|
|
184
|
-
ctx.save();
|
|
185
|
-
ctx.fillStyle = '#e8e8e8';
|
|
186
|
-
ctx.fillRect(x, y, w, h);
|
|
187
|
-
ctx.beginPath();
|
|
188
|
-
ctx.rect(x, y, w, h);
|
|
189
|
-
ctx.clip();
|
|
190
|
-
ctx.strokeStyle = '#888';
|
|
191
|
-
ctx.lineWidth = 0.8;
|
|
192
|
-
const step = 5;
|
|
193
|
-
for (let d = -h; d < w + h; d += step) {
|
|
194
|
-
ctx.beginPath();
|
|
195
|
-
ctx.moveTo(x + d, y);
|
|
196
|
-
ctx.lineTo(x + d + h, y + h);
|
|
197
|
-
ctx.stroke();
|
|
198
|
-
}
|
|
199
|
-
ctx.restore();
|
|
200
|
-
}
|
package/src/cli/report.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ingest } from './ingest.js';
|
|
|
12
12
|
import { computeSummary } from './analyzers/summary.js';
|
|
13
13
|
import { detectEdgeExits } from './analyzers/edge-exit.js';
|
|
14
14
|
import { rankTriage } from './analyzers/triage.js';
|
|
15
|
+
import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
|
|
15
16
|
import { VERSION } from '../shared/constants.js';
|
|
16
17
|
|
|
17
18
|
export async function run(args) {
|
|
@@ -44,19 +45,43 @@ export async function run(args) {
|
|
|
44
45
|
process.exit(1);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// 3. Analyze — compute summaries, detect edge exits, rank by triage priority.
|
|
49
|
+
// config.phaseScope (0.6.1) filters which trials feed the analyzers so
|
|
50
|
+
// scores can honor pre-registered phase scoping; renderers below still get
|
|
51
|
+
// the FULL participants, so the visual evidence is never hidden.
|
|
52
|
+
const scoredParticipants = applyPhaseScope(participants, config.phaseScope);
|
|
53
|
+
if (scoredParticipants !== participants) {
|
|
54
|
+
console.log(`\nPhase scope active (${describePhaseScope(config.phaseScope)}):`);
|
|
55
|
+
console.log(` scores count per-trial signals inside the scope only; ambient session`);
|
|
56
|
+
console.log(` signals (sidebar, shortcuts, viewport shifts, zoom) stay session-wide.`);
|
|
57
|
+
// A configured phase name that matches no trial is almost always a typo. For
|
|
58
|
+
// an `include`, it silently filters every trial and reports the cohort clean.
|
|
59
|
+
const unmatched = findUnmatchedPhaseScopePhases(participants, config.phaseScope);
|
|
60
|
+
if (unmatched.length > 0) {
|
|
61
|
+
console.warn(`[cyborg-hunter] phaseScope names match NO trial in the data: ` +
|
|
62
|
+
`${unmatched.join(', ')} — likely a typo; scored trials may be wrongly emptied.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const summaries = computeSummary(scoredParticipants, config);
|
|
66
|
+
const edgeExits = detectEdgeExits(scoredParticipants, config);
|
|
50
67
|
const triage = rankTriage(summaries, edgeExits, config);
|
|
51
68
|
|
|
52
69
|
const flaggedHard = triage.filter(t => t.hardTriggered).length;
|
|
53
70
|
const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
|
|
54
71
|
const clean = triage.length - flaggedHard - flaggedSoft;
|
|
55
72
|
|
|
73
|
+
// Two DIFFERENT numbers live in this report and used to share the word
|
|
74
|
+
// "flagged": the tier counts below come from the LIBRARY's two-tier
|
|
75
|
+
// screening (hard count thresholds / soft score vs its threshold), while
|
|
76
|
+
// triage.md is ORDERED by the CLI's separate composite triage score
|
|
77
|
+
// (5×paste + 5×copy + 3×sidebar + 1×tab-away) within each tier. Label both
|
|
78
|
+
// explicitly so the console summary can't be read as "top N of triage.md".
|
|
56
79
|
console.log(`\nAnalyzing...`);
|
|
57
|
-
console.log(`
|
|
58
|
-
console.log(`
|
|
59
|
-
console.log(`
|
|
80
|
+
console.log(` Hard-flagged (hard signal crossed its count threshold): ${flaggedHard}`);
|
|
81
|
+
console.log(` Soft-flagged (library soft score >= its threshold): ${flaggedSoft}`);
|
|
82
|
+
console.log(` Clean: ${clean}`);
|
|
83
|
+
console.log(` Triage.md orders tier-first (hard > soft > clean), then by the CLI`);
|
|
84
|
+
console.log(` triage score (5xpaste + 5xcopy + 3xsidebar + 1xtab-away) within a tier.`);
|
|
60
85
|
|
|
61
86
|
// 4. Render outputs
|
|
62
87
|
console.log(`\nRendering...`);
|
|
@@ -92,19 +117,28 @@ export async function run(args) {
|
|
|
92
117
|
|
|
93
118
|
if (canvas) {
|
|
94
119
|
const { renderTrajectories } = await import('./renderers/trajectories.js');
|
|
95
|
-
const {
|
|
120
|
+
const { renderSessionTimelines } = await import('./renderers/session-timeline.js');
|
|
96
121
|
const { renderTypingProfiles } = await import('./renderers/typing-profile.js');
|
|
97
122
|
|
|
98
123
|
// Create images/ subdirectory for visual outputs
|
|
99
124
|
mkdirSync(join(config.outputDir, 'images'), { recursive: true });
|
|
100
125
|
|
|
101
126
|
await renderTrajectories(participants, triage, config);
|
|
102
|
-
await
|
|
127
|
+
await renderSessionTimelines(participants, config);
|
|
103
128
|
await renderTypingProfiles(participants, config);
|
|
104
129
|
visualsRendered = true;
|
|
105
130
|
}
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
// Replay assets — per-participant JSONP models under replay/, lazy-loaded
|
|
134
|
+
// by the HTML report. Size is printed because dom-tier models dominate
|
|
135
|
+
// the report's disk footprint.
|
|
136
|
+
const { renderReplayAssets } = await import('./renderers/replay-assets.js');
|
|
137
|
+
const replayAssets = renderReplayAssets(participants, config.outputDir);
|
|
138
|
+
if (replayAssets.count > 0) {
|
|
139
|
+
console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
108
142
|
// HTML index page — references images/ folder (not base64-embedded)
|
|
109
143
|
const { renderHtmlIndex } = await import('./renderers/html-index.js');
|
|
110
144
|
await renderHtmlIndex(summaries, triage, participants, config, visualsRendered);
|