cyborg-hunter 0.9.2 → 0.10.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/CHANGELOG.md +52 -0
- package/CITATION.cff +1 -1
- package/README.md +29 -29
- package/dist/ch.js +3 -3
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +1 -1
- package/dist/cyborg-hunter.min.js +4 -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 +3 -2
- package/src/cli/extract-core.js +70 -4
- package/src/cli/segment-reassembly.js +204 -0
- package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +21 -7
- package/src/jspsych/extension-guard-honeypot.js +13 -6
- package/src/oneliner/adapters/jspsych-extension.js +196 -0
- package/src/oneliner/adapters/jspsych.js +483 -0
- package/src/oneliner/adapters/vanilla.js +420 -0
- package/src/oneliner/api.js +151 -0
- package/src/oneliner/boot.js +270 -0
- package/src/oneliner/config.js +122 -0
- package/src/oneliner/debug.js +137 -0
- package/src/oneliner/entry.js +8 -0
- package/src/oneliner/errors.js +219 -0
- package/src/oneliner/guards.js +67 -0
- package/src/oneliner/participant-id.js +49 -0
- package/src/oneliner/replay-loader.js +296 -0
- package/src/oneliner/segment-diff.js +68 -0
- package/src/oneliner/segmenter.js +186 -0
- package/src/replay/recorder.js +5 -1
- package/src/shared/constants.js +1 -1
package/src/cli/extract-core.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { TRIAL_REPORT_FIELDS } from '../shared/schema.js';
|
|
17
17
|
import { getByPath } from '../shared/paths.js';
|
|
18
|
+
import { collectSegments, reassembleSegments, rebaseTrialReport } from './segment-reassembly.js';
|
|
18
19
|
|
|
19
20
|
// Extracts integrity trial data from a single participant's raw JSON.
|
|
20
21
|
// Returns { participantId, trials, warnings, metadata }.
|
|
@@ -53,6 +54,27 @@ export function extractIntegrityData(raw, config) {
|
|
|
53
54
|
trials = raw.trials
|
|
54
55
|
.filter(t => t && t[intField])
|
|
55
56
|
.map(t => ({ ...t, ...t[intField] }));
|
|
57
|
+
// One-line setup (0.10.0) across several pages: each page has its own
|
|
58
|
+
// performance.now() origin, recorded as integritySegment.pageOrigin.
|
|
59
|
+
// Re-base trials from later pages onto the first page's origin so the
|
|
60
|
+
// session timeline and the per-trial anchors below stay on one clock.
|
|
61
|
+
// Only the page-clock fields move (anchors + absolute-time event arrays,
|
|
62
|
+
// listed in segment-reassembly.js); trial-relative mouseTrack/elementTrace
|
|
63
|
+
// times must NOT move, or trajectories misplace every later-page trial.
|
|
64
|
+
// Single-page data, and data without segments, is untouched. A row whose
|
|
65
|
+
// trial was not segmented itself (it carries only the closing
|
|
66
|
+
// integritySegmentFinal) takes that segment's page.
|
|
67
|
+
const rowOrigin = t => t?.integritySegment?.pageOrigin ?? t?.integritySegmentFinal?.pageOrigin;
|
|
68
|
+
if (raw.trials.some(t => typeof rowOrigin(t) === 'number')) {
|
|
69
|
+
const origin0 = collectSegments(raw)[0]?.pageOrigin;
|
|
70
|
+
if (typeof origin0 === 'number') {
|
|
71
|
+
trials = trials.map(trial => {
|
|
72
|
+
const origin = rowOrigin(trial);
|
|
73
|
+
return (typeof origin === 'number' && origin !== origin0)
|
|
74
|
+
? rebaseTrialReport(trial, origin - origin0) : trial;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
56
78
|
// jsPsych extension data carries trialStart_perfNow per trial (set by the
|
|
57
79
|
// wrapper's on_load), so normalization takes the exact-subtraction fast
|
|
58
80
|
// path. Without it, renderers see only session-absolute `start` values
|
|
@@ -156,7 +178,7 @@ export function extractIntegrityData(raw, config) {
|
|
|
156
178
|
}
|
|
157
179
|
}
|
|
158
180
|
|
|
159
|
-
const { session, score } = findSessionData(raw, config);
|
|
181
|
+
const { session, score } = findSessionData(raw, config, warnings);
|
|
160
182
|
if (session === null && trials.length > 0) {
|
|
161
183
|
warnings.push('No session-level integrity data — some signals unavailable (did the experiment call getSessionReport()?)');
|
|
162
184
|
}
|
|
@@ -173,6 +195,16 @@ export function extractIntegrityData(raw, config) {
|
|
|
173
195
|
if (finalizeError) {
|
|
174
196
|
warnings.push(`finalize() failed for this participant (cyborgHunterFinalizeError): ${finalizeError} — session data may be incomplete`);
|
|
175
197
|
}
|
|
198
|
+
// The one-line setup (0.10.0) has no finalize(); when it hits an internal
|
|
199
|
+
// error it drops a `cyborgHunterError` marker instead. Same three locations.
|
|
200
|
+
const oneLinerError = raw.cyborgHunterError
|
|
201
|
+
?? raw.metadata?.cyborgHunterError
|
|
202
|
+
?? (Array.isArray(raw.trials)
|
|
203
|
+
? raw.trials.find(t => t?.cyborgHunterError)?.cyborgHunterError
|
|
204
|
+
: undefined);
|
|
205
|
+
if (oneLinerError) {
|
|
206
|
+
warnings.push(`one-line setup reported an error for this participant (cyborgHunterError): ${oneLinerError} — data after that point may be incomplete`);
|
|
207
|
+
}
|
|
176
208
|
|
|
177
209
|
return {
|
|
178
210
|
participantId,
|
|
@@ -227,10 +259,17 @@ function looksLikeSessionData(obj) {
|
|
|
227
259
|
// 4. raw.cyborgHunter — native top-level location used by raw-DOM
|
|
228
260
|
// adopters before they adopt the
|
|
229
261
|
// metadata.integritySession mirror. Added 2026-05-26.
|
|
230
|
-
//
|
|
231
|
-
|
|
262
|
+
// 5. Rolling snapshot (0.10.0 one-line setup) — per-row integritySegment
|
|
263
|
+
// deltas, reassembled by src/cli/segment-reassembly.js.
|
|
264
|
+
// Returns { session, score }, both null if not found. `warnings` receives a
|
|
265
|
+
// note when a dumped session and rolling segments are both present. Not
|
|
266
|
+
// exported (ingest.js re-exports only extractIntegrityData and
|
|
267
|
+
// ruleChronologicalCompare); its one caller always passes `warnings`, and the
|
|
268
|
+
// `= []` default only keeps a warnings-less call from throwing.
|
|
269
|
+
function findSessionData(raw, config, warnings = []) {
|
|
232
270
|
let session = null;
|
|
233
271
|
let score = null;
|
|
272
|
+
let usedSegments = false;
|
|
234
273
|
|
|
235
274
|
// 0. Analyst-specified location, e.g. "payload.cyborgHunter" for pipelines
|
|
236
275
|
// that nest the getSessionReport() output somewhere non-standard. Falls
|
|
@@ -273,6 +312,26 @@ function findSessionData(raw, config) {
|
|
|
273
312
|
session = raw.cyborgHunter;
|
|
274
313
|
score = null;
|
|
275
314
|
}
|
|
315
|
+
// 5. Rolling snapshot (0.10.0 one-line setup): per-row integritySegment deltas,
|
|
316
|
+
// concatenated in segmentIndex order into the same shape finalize() dumps.
|
|
317
|
+
else {
|
|
318
|
+
const segs = collectSegments(raw);
|
|
319
|
+
if (segs.length > 0) {
|
|
320
|
+
const rolled = reassembleSegments(segs);
|
|
321
|
+
session = rolled.session;
|
|
322
|
+
score = rolled.score;
|
|
323
|
+
usedSegments = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A file carrying both is a study that mixed manual finalize() with the
|
|
328
|
+
// one-line setup. The dumped session is complete by construction, so it
|
|
329
|
+
// wins; say so rather than silently ignoring the segments. This also fires
|
|
330
|
+
// when branch 0 (the analyst's sessionIntegrityPath) found the session,
|
|
331
|
+
// since that leaves usedSegments false too.
|
|
332
|
+
if (session && !usedSegments && collectSegments(raw).length > 0) {
|
|
333
|
+
warnings.push('both a dumped integritySession and rolling segments (integritySegment) were found — the dumped integritySession was used; this file mixes manual mode and the one-line setup');
|
|
334
|
+
}
|
|
276
335
|
|
|
277
336
|
// When no separate integrityScore blob was saved, synthesize the authoritative
|
|
278
337
|
// score from the session object itself. getSessionReport() embeds the scoring
|
|
@@ -320,7 +379,14 @@ function scoreFromSession(session) {
|
|
|
320
379
|
// (or metadata). Each entry is { reason, start, end, duration, in_progress };
|
|
321
380
|
// the renderer keys off `t` (perfNow ms), so map start → t. Apps that hand-write
|
|
322
381
|
// a top-level `raw.guardFriction` object still take precedence over this.
|
|
382
|
+
// One-line setup across several pages (0.10.0 vanilla host): each page's
|
|
383
|
+
// entries carry that page's `pageOrigin`, and `t` is re-based onto the first
|
|
384
|
+
// segment's origin like the rest of the session; entries without one keep
|
|
385
|
+
// their `start`.
|
|
323
386
|
function findGuardViolations(raw) {
|
|
387
|
+
const origin0 = collectSegments(raw)[0]?.pageOrigin;
|
|
388
|
+
const offset = (v) => (typeof v.pageOrigin === 'number' && typeof origin0 === 'number')
|
|
389
|
+
? v.pageOrigin - origin0 : 0;
|
|
324
390
|
const parseArr = (v) => {
|
|
325
391
|
if (Array.isArray(v)) return v;
|
|
326
392
|
if (typeof v === 'string') { try { return JSON.parse(v); } catch { return null; } }
|
|
@@ -353,7 +419,7 @@ function findGuardViolations(raw) {
|
|
|
353
419
|
if (Array.isArray(arr)) {
|
|
354
420
|
const violations = arr
|
|
355
421
|
.filter(v => v && typeof v.start === 'number')
|
|
356
|
-
.map(v => ({ t: v.start, reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
|
|
422
|
+
.map(v => ({ t: v.start + offset(v), reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
|
|
357
423
|
// Fall through to the next source on an empty/violation-less candidate
|
|
358
424
|
// instead of locking onto it — otherwise an empty placeholder (e.g. a
|
|
359
425
|
// metadata mirror set to []) would shadow real violations on the trial rows.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/cli/segment-reassembly.js
|
|
2
|
+
// CLI side of the rolling snapshot (0.10.0 one-line setup). The browser cuts
|
|
3
|
+
// the monitor's session arrays into per-segment deltas
|
|
4
|
+
// (src/oneliner/segment-diff.js) and saves one `integritySegment` per data
|
|
5
|
+
// row; this module puts them back together into the same session object
|
|
6
|
+
// finalize() dumps as `integritySession`, plus the score.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no imports, no Node APIs — extract-core.js (bundled by the browser
|
|
9
|
+
// demo) depends on it.
|
|
10
|
+
|
|
11
|
+
// Field names that hold a performance.now()-style time. Everything else
|
|
12
|
+
// (duration_ms, ISO `timestamp` strings, counts) is left alone when re-basing.
|
|
13
|
+
// rebaseTimes() applies this at any depth, which is right for the SESSION
|
|
14
|
+
// arrays (every entry's t/start is a raw page performance.now()), but not for
|
|
15
|
+
// a whole trial report — use rebaseTrialReport() for those.
|
|
16
|
+
// Bare-number arrays are never shifted: editTimestamps holds absolute
|
|
17
|
+
// performance.now() values too, but the CLI only uses their differences
|
|
18
|
+
// (typing speed), so they are intentionally left on their own page's clock.
|
|
19
|
+
const TIME_KEYS = new Set(['t', 'start', 'startTime', 'trialStart_perfNow']);
|
|
20
|
+
|
|
21
|
+
// Mirrors ALIAS_KEYS in src/oneliner/segment-diff.js (a test pins the two
|
|
22
|
+
// equal): the alias key is not shipped in segments, so it is restored here
|
|
23
|
+
// pointing at its canonical key.
|
|
24
|
+
export const ALIAS_KEYS = { layoutShifts: 'viewportWidthShifts' };
|
|
25
|
+
|
|
26
|
+
// Trial-report fields that carry the page's performance.now() clock and so
|
|
27
|
+
// move with the page origin. Time base of each, from src/core:
|
|
28
|
+
// startTime performance.now() at startTrial (monitor.js startTrial)
|
|
29
|
+
// trialStart_perfNow performance.now() at on_load (jspsych extension on_load)
|
|
30
|
+
// pasteEvents[].t performance.now() (signals/clipboard.js)
|
|
31
|
+
// copyEvents[].t performance.now() (signals/clipboard.js)
|
|
32
|
+
// dropEvents[].t performance.now() (signals/clipboard.js)
|
|
33
|
+
// tabAwayEvents[].start performance.now() at leave (signals/focus.js)
|
|
34
|
+
// idleGaps[].t performance.now() (signals/focus.js)
|
|
35
|
+
// syntheticInsertions[].t performance.now() (signals/typing.js)
|
|
36
|
+
// foreignInputEvents[].t performance.now() (signals/typing.js)
|
|
37
|
+
// NOT shifted (left as they are):
|
|
38
|
+
// mouseTrack/mouseEvents[].t ms since trial start (signals/mouse.js)
|
|
39
|
+
// elementTrace[].t ms since trial start (signals/browser.js)
|
|
40
|
+
// mouseTrackingCappedAtMs, duration_ms durations
|
|
41
|
+
// editTimestamps absolute, bare numbers — see TIME_KEYS note
|
|
42
|
+
// anything else on the row (integrity, integritySegment, jsPsych columns)
|
|
43
|
+
const TRIAL_ANCHOR_KEYS = ['startTime', 'trialStart_perfNow'];
|
|
44
|
+
const TRIAL_PAGE_TIME_ARRAYS = ['pasteEvents', 'copyEvents', 'dropEvents', 'tabAwayEvents',
|
|
45
|
+
'idleGaps', 'syntheticInsertions', 'foreignInputEvents'];
|
|
46
|
+
|
|
47
|
+
// Returns a shallow copy of a merged trial report with only the page-clock
|
|
48
|
+
// fields above shifted by offsetMs. Offset 0 returns the input itself.
|
|
49
|
+
export function rebaseTrialReport(trial, offsetMs) {
|
|
50
|
+
if (!offsetMs || !trial || typeof trial !== 'object') return trial;
|
|
51
|
+
const out = { ...trial };
|
|
52
|
+
for (const k of TRIAL_ANCHOR_KEYS) {
|
|
53
|
+
if (typeof out[k] === 'number') out[k] += offsetMs;
|
|
54
|
+
}
|
|
55
|
+
for (const k of TRIAL_PAGE_TIME_ARRAYS) {
|
|
56
|
+
if (Array.isArray(out[k])) out[k] = rebaseTimes(out[k], offsetMs);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns a deep copy of `value` with every numeric property named t, start,
|
|
62
|
+
// startTime or trialStart_perfNow shifted by offsetMs, at any depth.
|
|
63
|
+
// Offset 0 returns the input itself (no copy), so a single-page session costs
|
|
64
|
+
// nothing.
|
|
65
|
+
export function rebaseTimes(value, offsetMs) {
|
|
66
|
+
if (!offsetMs) return value;
|
|
67
|
+
return shift(value, offsetMs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shift(value, offsetMs) {
|
|
71
|
+
if (Array.isArray(value)) return value.map(v => shift(v, offsetMs));
|
|
72
|
+
if (!value || typeof value !== 'object') return value;
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [k, v] of Object.entries(value)) {
|
|
75
|
+
out[k] = (TIME_KEYS.has(k) && typeof v === 'number') ? v + offsetMs : shift(v, offsetMs);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSegment(x) {
|
|
81
|
+
return !!x && typeof x === 'object' && !Array.isArray(x) && typeof x.segmentIndex === 'number';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gathers every segment saved in a participant file, sorted by segmentIndex.
|
|
85
|
+
// Sources: raw.trials[*].integritySegment, raw.trials[*].integritySegmentFinal
|
|
86
|
+
// (any row) and raw.integritySegments (array). Non-objects are skipped — a CSV
|
|
87
|
+
// cell that failed to JSON.parse stays a string. When the same index appears
|
|
88
|
+
// twice, the first occurrence wins.
|
|
89
|
+
export function collectSegments(raw) {
|
|
90
|
+
const found = [];
|
|
91
|
+
if (raw && Array.isArray(raw.trials)) {
|
|
92
|
+
for (const row of raw.trials) {
|
|
93
|
+
if (!row || typeof row !== 'object') continue;
|
|
94
|
+
if (isSegment(row.integritySegment)) found.push(row.integritySegment);
|
|
95
|
+
if (isSegment(row.integritySegmentFinal)) found.push(row.integritySegmentFinal);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (raw && Array.isArray(raw.integritySegments)) {
|
|
99
|
+
for (const s of raw.integritySegments) if (isSegment(s)) found.push(s);
|
|
100
|
+
}
|
|
101
|
+
// Array.prototype.sort is stable, so on equal indices the earlier-found
|
|
102
|
+
// segment stays first and the duplicate check below keeps it.
|
|
103
|
+
found.sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const s of found) {
|
|
106
|
+
if (out.length > 0 && out[out.length - 1].segmentIndex === s.segmentIndex) continue;
|
|
107
|
+
out.push(s);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Concatenates segment deltas in segmentIndex order into the finalize() dump
|
|
113
|
+
// shape: { pasteCount, copyCount, dropCount, <every array key>, layoutShifts,
|
|
114
|
+
// config } — score fields and libraryVersion are NOT in the session (finalize()
|
|
115
|
+
// stores them separately). Returns { session, score, pageOrigins } or null for
|
|
116
|
+
// no segments. Segments from a later page (different pageOrigin) have their
|
|
117
|
+
// times re-based to the first page's origin. Counters and score add up the
|
|
118
|
+
// pages (pageTotals below); single-page data keeps the last segment's score
|
|
119
|
+
// object as it is.
|
|
120
|
+
export function reassembleSegments(segments) {
|
|
121
|
+
if (!Array.isArray(segments) || segments.length === 0) return null;
|
|
122
|
+
const sorted = segments.slice().sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
123
|
+
|
|
124
|
+
const pageOrigins = [];
|
|
125
|
+
for (const s of sorted) {
|
|
126
|
+
if (!pageOrigins.includes(s.pageOrigin)) pageOrigins.push(s.pageOrigin);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const session = {};
|
|
130
|
+
for (const s of sorted) {
|
|
131
|
+
const offset = (typeof s.pageOrigin === 'number' && typeof pageOrigins[0] === 'number')
|
|
132
|
+
? s.pageOrigin - pageOrigins[0] : 0;
|
|
133
|
+
for (const [key, entries] of Object.entries(s.deltas || {})) {
|
|
134
|
+
if (!Array.isArray(entries)) continue;
|
|
135
|
+
session[key] = (session[key] || []).concat(rebaseTimes(entries, offset));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [alias, canonical] of Object.entries(ALIAS_KEYS)) {
|
|
139
|
+
if (!session[canonical]) session[canonical] = [];
|
|
140
|
+
session[alias] = session[canonical]; // same array, as in the monitor
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Counters and score are cumulative per monitor, and every page load runs a
|
|
144
|
+
// new monitor: the session total is the sum over pages of each page's last
|
|
145
|
+
// segment. `?? 0` matches finalize()'s destructuring defaults.
|
|
146
|
+
const lasts = pageLasts(sorted);
|
|
147
|
+
for (const k of ['pasteCount', 'copyCount', 'dropCount']) {
|
|
148
|
+
session[k] = lasts.reduce((sum, s) => sum + ((s.counters || {})[k] ?? 0), 0);
|
|
149
|
+
}
|
|
150
|
+
const withConfig = sorted.find(s => s.config);
|
|
151
|
+
session.config = withConfig ? withConfig.config : undefined;
|
|
152
|
+
|
|
153
|
+
return { session, score: sessionScore(lasts), pageOrigins };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The last segment of each page, in page order. A page is identified by its
|
|
157
|
+
// pageOrigin, not by a run of consecutive segments: a page shown again from
|
|
158
|
+
// the back/forward cache keeps its origin and its monitor, so its last
|
|
159
|
+
// segment already holds everything that monitor counted, earlier visit
|
|
160
|
+
// included (summing per run would count the first visit twice).
|
|
161
|
+
function pageLasts(sorted) {
|
|
162
|
+
const byOrigin = new Map();
|
|
163
|
+
for (const s of sorted) byOrigin.set(s.pageOrigin, s); // sorted: the last one wins
|
|
164
|
+
return [...byOrigin.values()].sort((a, b) => a.segmentIndex - b.segmentIndex);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// One page: that segment's score object, untouched. Several pages: the same
|
|
168
|
+
// shape as the monitor's (src/core/scoring.js:130-139, monitor.js:413-420),
|
|
169
|
+
// added up. Each hard signal's count is the sum of the pages' counts, its
|
|
170
|
+
// threshold the last page's, triggered = count >= threshold; softScore and
|
|
171
|
+
// trialsCompleted are sums; softScoreThreshold is the last page's.
|
|
172
|
+
function sessionScore(lasts) {
|
|
173
|
+
const scored = lasts.filter(s => s.score && typeof s.score === 'object');
|
|
174
|
+
if (scored.length === 0) return lasts[lasts.length - 1].score ?? null;
|
|
175
|
+
const last = scored[scored.length - 1].score;
|
|
176
|
+
if (lasts.length === 1) return last;
|
|
177
|
+
|
|
178
|
+
const hardScore = {};
|
|
179
|
+
let softScore = 0;
|
|
180
|
+
let trialsCompleted = 0;
|
|
181
|
+
for (const { score } of scored) {
|
|
182
|
+
for (const [k, h] of Object.entries(score.hardScore || {})) {
|
|
183
|
+
if (!h || typeof h !== 'object') continue;
|
|
184
|
+
if (!hardScore[k]) hardScore[k] = { count: 0, threshold: undefined, triggered: false };
|
|
185
|
+
hardScore[k].count += h.count ?? 0;
|
|
186
|
+
if (h.threshold !== undefined) hardScore[k].threshold = h.threshold;
|
|
187
|
+
}
|
|
188
|
+
softScore += score.softScore ?? 0;
|
|
189
|
+
trialsCompleted += score.trialsCompleted ?? 0;
|
|
190
|
+
}
|
|
191
|
+
for (const k of Object.keys(hardScore)) {
|
|
192
|
+
const lastThreshold = last.hardScore?.[k]?.threshold;
|
|
193
|
+
if (lastThreshold !== undefined) hardScore[k].threshold = lastThreshold;
|
|
194
|
+
hardScore[k].triggered = typeof hardScore[k].threshold === 'number' && hardScore[k].count >= hardScore[k].threshold;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
...last,
|
|
198
|
+
hardScore,
|
|
199
|
+
softScore,
|
|
200
|
+
softScoreThreshold: last.softScoreThreshold,
|
|
201
|
+
anyHardTriggered: Object.values(hardScore).some(h => h.triggered),
|
|
202
|
+
trialsCompleted
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
|
|
|
35
35
|
class CyborgHunterReplayExtension {
|
|
36
36
|
static info = {
|
|
37
37
|
name: 'cyborg-hunter-replay',
|
|
38
|
-
version: '0.
|
|
38
|
+
version: '0.10.0',
|
|
39
39
|
data: {} // per-trial return is {}; session meta goes via addProperties
|
|
40
40
|
};
|
|
41
41
|
|
|
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
|
|
|
10
10
|
// and package.json). Hand-bumped on each release; if you forget, the
|
|
11
11
|
// jsPsych developer console shows a stale number — the actual version
|
|
12
12
|
// attached to data is read from window.CyborgHunter.VERSION at runtime.
|
|
13
|
-
version: '0.
|
|
13
|
+
version: '0.10.0',
|
|
14
14
|
data: { integrity: { type: 'object' } }
|
|
15
15
|
};
|
|
16
16
|
|
|
@@ -954,6 +954,9 @@ export function exitFullscreenFnOf(doc) {
|
|
|
954
954
|
active: state.active,
|
|
955
955
|
in_violation: !!state.currentViolation,
|
|
956
956
|
current_reason: state.currentViolation ? state.currentViolation.reason : null,
|
|
957
|
+
// true while violations are only logged (no curtain); the entry
|
|
958
|
+
// trial switches it off — enforcement starts at that mark.
|
|
959
|
+
observe_only: state.observeOnly,
|
|
957
960
|
};
|
|
958
961
|
}
|
|
959
962
|
|
|
@@ -981,7 +984,11 @@ export function exitFullscreenFnOf(doc) {
|
|
|
981
984
|
logDebug('entry_trial.on_finish', { diagnostics: getDiagnostics() });
|
|
982
985
|
requestFullscreen();
|
|
983
986
|
_setTimeout(() => {
|
|
984
|
-
|
|
987
|
+
// observeOnly: false explicitly — start() keeps the prior
|
|
988
|
+
// value when the option is absent, so a session started
|
|
989
|
+
// observe-only (the one-liner's friction before its mark)
|
|
990
|
+
// would otherwise never enforce from this mark on.
|
|
991
|
+
const token = start({ jsPsych: state.jsPsych, observeOnly: false });
|
|
985
992
|
// Non-enumerable stash so the experiment's on_finish can
|
|
986
993
|
// pass the token to stop() without exposing it on a
|
|
987
994
|
// discoverable property name. configurable:true so the
|
|
@@ -1118,11 +1125,18 @@ export function exitFullscreenFnOf(doc) {
|
|
|
1118
1125
|
};
|
|
1119
1126
|
|
|
1120
1127
|
Object.freeze(api);
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1128
|
+
// A second copy of this core on the page (ch.js bundles it, so ch.js plus
|
|
1129
|
+
// this file's own <script> tag, in either order) keeps the first
|
|
1130
|
+
// definition instead of throwing "Cannot redefine property".
|
|
1131
|
+
if (!global.GuardFriction) {
|
|
1132
|
+
Object.defineProperty(global, 'GuardFriction', {
|
|
1133
|
+
value: api,
|
|
1134
|
+
writable: false,
|
|
1135
|
+
configurable: false,
|
|
1136
|
+
});
|
|
1137
|
+
} else {
|
|
1138
|
+
console.error('[cyborg-hunter] Not redefining GuardFriction: GuardFriction is already defined, so two scripts on this page include the friction guard. Fix: keep one of them (ch.js already contains the friction guard). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
|
|
1139
|
+
}
|
|
1126
1140
|
})(window);
|
|
1127
1141
|
// ----- jsPsych extension adapter -----
|
|
1128
1142
|
// Wraps the GuardFriction core (above) in the jsPsych extension
|
|
@@ -1144,7 +1158,7 @@ class GuardFrictionExtension {
|
|
|
1144
1158
|
// Hand-bumped on each release to track the package version
|
|
1145
1159
|
// (package.json / src/shared/constants.js). Shown in the jsPsych developer
|
|
1146
1160
|
// console only; the runtime library version is independent.
|
|
1147
|
-
version: '0.
|
|
1161
|
+
version: '0.10.0',
|
|
1148
1162
|
data: {}
|
|
1149
1163
|
};
|
|
1150
1164
|
|
|
@@ -421,11 +421,18 @@
|
|
|
421
421
|
};
|
|
422
422
|
|
|
423
423
|
Object.freeze(api);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
424
|
+
// A second copy of this core on the page (ch.js bundles it, so ch.js plus
|
|
425
|
+
// this file's own <script> tag, in either order) keeps the first
|
|
426
|
+
// definition instead of throwing "Cannot redefine property".
|
|
427
|
+
if (!global.GuardHoneypot) {
|
|
428
|
+
Object.defineProperty(global, 'GuardHoneypot', {
|
|
429
|
+
value: api,
|
|
430
|
+
writable: false,
|
|
431
|
+
configurable: false,
|
|
432
|
+
});
|
|
433
|
+
} else {
|
|
434
|
+
console.error('[cyborg-hunter] Not redefining GuardHoneypot: GuardHoneypot is already defined, so two scripts on this page include the honeypot. Fix: keep one of them (ch.js already contains the honeypot). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
|
|
435
|
+
}
|
|
429
436
|
})(window);
|
|
430
437
|
// ----- jsPsych extension adapter -----
|
|
431
438
|
// Wraps the GuardHoneypot core (above) in the jsPsych extension
|
|
@@ -442,7 +449,7 @@
|
|
|
442
449
|
class GuardHoneypotExtension {
|
|
443
450
|
static info = {
|
|
444
451
|
name: 'guard-honeypot',
|
|
445
|
-
version: '0.
|
|
452
|
+
version: '0.10.0',
|
|
446
453
|
// Per-trial fields written by on_finish. Reflect violations for this
|
|
447
454
|
// trial only; jsPsych spreads them onto each trial row (CSV columns).
|
|
448
455
|
// Session totals are written as global properties by finalize().
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/oneliner/adapters/jspsych-extension.js
|
|
2
|
+
// The jsPsych extension ch.js injects into every trial (adapters/jspsych.js
|
|
3
|
+
// adds it to initJsPsych's list and to each trial object). Unlike the manual
|
|
4
|
+
// extension (src/jspsych/extension-cyborg-hunter.js) it creates no monitor:
|
|
5
|
+
// boot already has one, kept inside a trial by the segmenter. So:
|
|
6
|
+
// on_load rotate: close the span before this trial (a "gap"), open the
|
|
7
|
+
// host trial;
|
|
8
|
+
// on_finish cut: close the host trial, turn everything since the last cut
|
|
9
|
+
// into a segment, open the next gap span. The returned object is
|
|
10
|
+
// merged into this trial's own row by jsPsych (jspsych.js 7.3.1
|
|
11
|
+
// :2772, :2814-2823), before the trial's own on_finish, so
|
|
12
|
+
// whatever save the researcher already does carries the segment
|
|
13
|
+
// and the running totals up to this row.
|
|
14
|
+
//
|
|
15
|
+
// The class keeps info.name 'cyborg-hunter' (jsPsych keys its extension
|
|
16
|
+
// instances by name) and exposes `.monitor`, which the replay extension reads
|
|
17
|
+
// through jsPsych.extensions['cyborg-hunter'].monitor.
|
|
18
|
+
//
|
|
19
|
+
// A trial listing the manual class by name (a researcher-named entry with
|
|
20
|
+
// params.trialId) reaches this instance too, with its params, so its trialId
|
|
21
|
+
// names the segment.
|
|
22
|
+
//
|
|
23
|
+
// A late on_load is dropped. A synchronous plugin (call-function) finishes
|
|
24
|
+
// inside its own trial() call, and jsPsych runs that trial's load callback
|
|
25
|
+
// only afterwards (jspsych.js 7.3.1 :3046-3056, :3101-3103), in one of two
|
|
26
|
+
// orders:
|
|
27
|
+
// - nextTrial runs synchronously from finishTrial: the next trial's
|
|
28
|
+
// on_start and on_load come first, then the stale on_load, which would
|
|
29
|
+
// close the next trial's span as a gap and reopen it unnamed;
|
|
30
|
+
// - post_trial_gap / default_iti > 0 defer nextTrial: the stale on_load
|
|
31
|
+
// comes right after its own on_finish and would open a span for a trial
|
|
32
|
+
// that already ended.
|
|
33
|
+
// A third order: when the next trial's trial() returns a Promise (jsPsych 7
|
|
34
|
+
// audio plugins, custom plugins), jsPsych leaves its load callback to the
|
|
35
|
+
// plugin (:3099-3103), so the stale on_load arrives after the next trial's
|
|
36
|
+
// on_start but before that trial's own on_load.
|
|
37
|
+
// An index check (current_trial_global) catches only the first order: in the
|
|
38
|
+
// second the index has not moved yet. So on_start arms the load and records
|
|
39
|
+
// the params object jsPsych passed; on_load counts only while armed and only
|
|
40
|
+
// with that same object; on_finish disarms. jsPsych hands one trial's
|
|
41
|
+
// on_start and on_load the same object (extension.params of the same trial,
|
|
42
|
+
// :3027-3030, :3046-3054), and the adapter gives every trial its own copy of
|
|
43
|
+
// the injected entry, so the stale call is rejected in all three orders.
|
|
44
|
+
// (jsPsych calls the extension's on_start on every trial that lists it, so
|
|
45
|
+
// every real on_load is armed.)
|
|
46
|
+
//
|
|
47
|
+
// After the session has ended (the final hook ran, ctx.jspsych.finalized),
|
|
48
|
+
// both hooks leave the segmenter alone: rows of a second jsPsych instance
|
|
49
|
+
// that runs afterwards get no cyborgHunterError ('finished') marker; the
|
|
50
|
+
// adapter warned about the second instance when it was created. They also
|
|
51
|
+
// stand down when the deferred session start failed (ctx.bootError, boot.js
|
|
52
|
+
// failDeferred), which marked every row already.
|
|
53
|
+
//
|
|
54
|
+
// ctx (set by installJsPsychAdapter): { monitor, segmenter, jspsych, debug? }.
|
|
55
|
+
// None of the hooks throws into jsPsych: a failure becomes cyborgHunterError
|
|
56
|
+
// on the row. With no ctx (ch.js failed or stood down, boot.js; the class is
|
|
57
|
+
// still registered so researcher trials typed jsPsychCyborgHunter run) every
|
|
58
|
+
// hook does nothing.
|
|
59
|
+
//
|
|
60
|
+
// Leftovers of manual wiring on a half-migrated page, where
|
|
61
|
+
// jsPsychCyborgHunter is this class: participantId / preset in the
|
|
62
|
+
// initJsPsych entry's params (initialize) and the on_finish finalize() call
|
|
63
|
+
// each warn once from the catalogue and are otherwise ignored; finalize()
|
|
64
|
+
// existing at all keeps the researcher's save code after it running.
|
|
65
|
+
|
|
66
|
+
import { MESSAGES } from '../errors.js';
|
|
67
|
+
|
|
68
|
+
export class OneLinerExtension {
|
|
69
|
+
static info = {
|
|
70
|
+
name: 'cyborg-hunter',
|
|
71
|
+
// Hand-bumped with the package version (tests/cli/version-invariant.test.js
|
|
72
|
+
// pins it to package.json by reading this literal).
|
|
73
|
+
version: '0.10.0',
|
|
74
|
+
data: {
|
|
75
|
+
integrity: { type: 'object' },
|
|
76
|
+
integritySegment: { type: 'object' }
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
static ctx = null;
|
|
81
|
+
|
|
82
|
+
constructor(jsPsych) {
|
|
83
|
+
this.jsPsych = jsPsych;
|
|
84
|
+
this._trialStart_perfNow = null;
|
|
85
|
+
this._loadError = null;
|
|
86
|
+
this._loadArmed = false;
|
|
87
|
+
this._armedParams = undefined;
|
|
88
|
+
this._paramsWarned = false;
|
|
89
|
+
this._finalizeWarned = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get monitor() {
|
|
93
|
+
return OneLinerExtension.ctx ? OneLinerExtension.ctx.monitor : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The monitor already exists (boot); nothing to set up. jsPsych passes the
|
|
97
|
+
// initJsPsych entry's params (a researcher's entry wins the dedupe over
|
|
98
|
+
// ours, adapters/jspsych.js).
|
|
99
|
+
initialize(params) {
|
|
100
|
+
if (this._paramsWarned || !params) return;
|
|
101
|
+
if (params.participantId !== undefined || params.preset !== undefined) {
|
|
102
|
+
this._paramsWarned = true;
|
|
103
|
+
console.warn(MESSAGES.extensionParamsIgnored());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The manual extension's end-of-session call. ch.js ends the session from
|
|
108
|
+
// initJsPsych's on_finish (adapters/jspsych.js), before the researcher's.
|
|
109
|
+
finalize() {
|
|
110
|
+
if (this._finalizeWarned) return;
|
|
111
|
+
this._finalizeWarned = true;
|
|
112
|
+
console.warn(MESSAGES.finalizeNotNeeded());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// jsPsych 7 calls on_start on every trial that lists the extension, before
|
|
116
|
+
// the plugin's trial(): arm this trial's on_load, for these params only.
|
|
117
|
+
on_start(params) {
|
|
118
|
+
this._loadArmed = true;
|
|
119
|
+
this._armedParams = params;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
on_load(params) {
|
|
123
|
+
var ctx = OneLinerExtension.ctx;
|
|
124
|
+
// A late on_load (see the header) is dropped before anything is reset:
|
|
125
|
+
// the trial now open keeps its anchor and any rotate error.
|
|
126
|
+
if (!this._loadArmed || params !== this._armedParams) return;
|
|
127
|
+
this._loadArmed = false;
|
|
128
|
+
this._armedParams = undefined;
|
|
129
|
+
this._loadError = null;
|
|
130
|
+
if (!ctx || ctx.bootError || (ctx.jspsych && ctx.jspsych.finalized)) return;
|
|
131
|
+
try {
|
|
132
|
+
// Same anchor as the manual extension: ingest subtracts it from the
|
|
133
|
+
// tab-away `start` times (same performance.now() clock).
|
|
134
|
+
this._trialStart_perfNow = performance.now();
|
|
135
|
+
var r = ctx.segmenter.rotate({
|
|
136
|
+
trialId: (params && params.trialId) || 'trial-' + this.jsPsych.getProgress().current_trial_global,
|
|
137
|
+
phase: (params && params.phase) || null,
|
|
138
|
+
// ?? not ||: an explicit decoyAnswer:false (skip the decoy) must reach
|
|
139
|
+
// the core as false.
|
|
140
|
+
decoyAnswer: params && params.decoyAnswer !== undefined && params.decoyAnswer !== null ? params.decoyAnswer : null,
|
|
141
|
+
experimentContainer: (params && params.experimentContainer) || null
|
|
142
|
+
});
|
|
143
|
+
if (r && r.error) this._loadError = r.error;
|
|
144
|
+
} catch (e) {
|
|
145
|
+
this._loadError = String((e && e.message) || e);
|
|
146
|
+
}
|
|
147
|
+
// jsPsych's prepareDom wiped <body>, badge included, before the first
|
|
148
|
+
// trial; this puts it back while that trial is on screen.
|
|
149
|
+
try { if (ctx.debug && ctx.debug.refresh) ctx.debug.refresh(); } catch (_) { /* a debug aid */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
on_finish(_params) {
|
|
153
|
+
var ctx = OneLinerExtension.ctx;
|
|
154
|
+
this._loadArmed = false;
|
|
155
|
+
this._armedParams = undefined;
|
|
156
|
+
if (!ctx || ctx.bootError || (ctx.jspsych && ctx.jspsych.finalized)) return {};
|
|
157
|
+
// Timed only under data-debug: no clock reads otherwise.
|
|
158
|
+
var t0 = ctx.debug ? performance.now() : 0;
|
|
159
|
+
var out;
|
|
160
|
+
try {
|
|
161
|
+
var idx = this.jsPsych.getProgress().current_trial_global;
|
|
162
|
+
var r = ctx.segmenter.cut({ source: 'host', nextTrialId: 'gap-' + idx });
|
|
163
|
+
if (r && r.segment) {
|
|
164
|
+
var report = r.trialReport || {};
|
|
165
|
+
report.trialStart_perfNow = this._trialStart_perfNow;
|
|
166
|
+
out = {
|
|
167
|
+
integrity: report,
|
|
168
|
+
integritySegment: r.segment,
|
|
169
|
+
integrityPasteCount: r.segment.counters.pasteCount,
|
|
170
|
+
integrityCopyCount: r.segment.counters.copyCount,
|
|
171
|
+
integrityDropCount: r.segment.counters.dropCount,
|
|
172
|
+
integritySoftScore: r.segment.score.softScore,
|
|
173
|
+
integrityAnyHardTriggered: r.segment.score.anyHardTriggered
|
|
174
|
+
};
|
|
175
|
+
// A segment that comes with an error is complete; only the next span
|
|
176
|
+
// failed to open. Save it, and mark the row.
|
|
177
|
+
var err = r.error || this._loadError;
|
|
178
|
+
if (err) out.cyborgHunterError = err;
|
|
179
|
+
if (ctx.jspsych) ctx.jspsych.segmentsWritten = (ctx.jspsych.segmentsWritten || 0) + 1;
|
|
180
|
+
} else {
|
|
181
|
+
out = { cyborgHunterError: (r && r.error) || this._loadError || 'no segment' };
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
out = { cyborgHunterError: String((e && e.message) || e) };
|
|
185
|
+
}
|
|
186
|
+
this._trialStart_perfNow = null;
|
|
187
|
+
this._loadError = null;
|
|
188
|
+
try {
|
|
189
|
+
// The timing covers ch.js's cut plus output assembly, not jsPsych's own
|
|
190
|
+
// merge of this output into the data row.
|
|
191
|
+
if (ctx.debug && ctx.debug.stats) ctx.debug.stats().segmentWriteMs.push(performance.now() - t0);
|
|
192
|
+
if (ctx.debug && ctx.debug.refresh) ctx.debug.refresh(); // after the timing push
|
|
193
|
+
} catch (_) { /* debug counters are optional */ }
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
}
|