cyborg-hunter 0.3.0 → 0.4.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.
@@ -1,91 +1,71 @@
1
- // src/jspsych/extension.js
2
- // jsPsych extension adapter for Cyborg Hunter.
3
- // Translates jsPsych's trial lifecycle into CyborgHunter API calls.
4
- //
5
- // Two main usage patterns:
6
- // 1. Monitor everything (default) — every trial gets startTrial/endTrial
7
- // 2. Explicit opt-in (autoMonitor: false) — monitor only specified trials
8
- // (trials must pass { trialId: '...' } in their extension parameters)
9
- //
10
- // IMPORTANT: This file does NOT import from the core library. It references
11
- // window.CyborgHunter at runtime. The researcher must load cyborg-hunter.min.js
12
- // before this script. This avoids bundling the core into the extension.
1
+ // jsPsych extension adapter for cyborg-hunter.
2
+ // Loaded as a script tag AFTER cyborg-hunter.min.js — references
3
+ // window.CyborgHunter at runtime rather than importing the core, so the
4
+ // extension stays a thin (~2KB) adapter even though the core is ~28KB.
13
5
 
14
6
  class CyborgHunterExtension {
15
- // jsPsych reads this static info object to register the extension.
16
- // The `data` field declares what on_finish() will return — here, an
17
- // `integrity` object containing the trial's signal report.
18
7
  static info = {
19
8
  name: 'cyborg-hunter',
20
- version: '0.1.0',
9
+ // Must match the cyborg-hunter library version (src/shared/constants.js
10
+ // and package.json). Hand-bumped on each release; if you forget, the
11
+ // jsPsych developer console shows a stale number — the actual version
12
+ // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
+ version: '0.3.0',
21
14
  data: { integrity: { type: 'object' } }
22
15
  };
23
16
 
24
17
  constructor(jsPsych) {
25
18
  this.jsPsych = jsPsych;
26
- this.monitor = null; // CyborgHunter monitor instance (set in initialize)
27
- this.params = {}; // extension-level params from jsPsych config
28
- this._monitoring = false; // whether the current trial is being monitored
29
- this._trialStart_perfNow = null; // performance.now() captured at on_load
19
+ this.monitor = null;
20
+ this.params = {};
21
+ this._monitoring = false;
22
+ this._trialStart_perfNow = null;
30
23
  }
31
24
 
32
- // Called once when jsPsych initializes extensions (before any trials run).
33
- // `params` comes from the experiment's extensions config, e.g.:
34
- // extensions: [{ type: jsPsychCyborgHunter, params: { preset: 'standard' } }]
35
- //
36
- // We pull out extension-specific keys (autoMonitor, excludeTrialTypes) and
37
- // pass everything else through to CyborgHunter.init() as monitor config.
25
+ // jsPsych calls this once during initJsPsych setup.
26
+ // Splits extension-specific keys out and forwards the rest to the monitor.
38
27
  initialize(params) {
39
28
  this.params = params;
40
29
  const { autoMonitor, excludeTrialTypes, ...monitorConfig } = params;
41
30
 
42
- // Look up the global CyborgHunter object (or its backward-compat alias).
43
- // The researcher must include cyborg-hunter.min.js before this extension.
44
31
  const CyborgHunter = window.CyborgHunter || window.IntegrityMonitor;
45
32
  if (!CyborgHunter) {
46
33
  throw new Error('CyborgHunter not found. Load cyborg-hunter.min.js before the jsPsych extension.');
47
34
  }
48
35
 
49
- // init() creates the monitor; startSession() begins session-scoped listeners.
50
36
  this.monitor = CyborgHunter.init(monitorConfig);
51
37
  this.monitor.startSession();
52
38
  }
53
39
 
54
40
  // jsPsych 7 unconditionally calls on_start for every trial that lists this
55
- // extension — even though the work happens in on_load (after DOM render).
56
- // Without this no-op, the very next trial after the first throws
41
+ // extension, even though the actual setup happens in on_load (after DOM
42
+ // render). Without this no-op, the very next trial throws
57
43
  // "this.extensions['cyborg-hunter'].on_start is not a function".
58
44
  on_start(_params) {}
59
45
 
60
- // Called at the start of each trial, after the trial's DOM is rendered.
61
- // `params` here are per-trial extension parameters, e.g.:
62
- // extensions: [{ type: jsPsychCyborgHunter, params: { trialId: 'rule-3' } }]
46
+ // Called per trial after DOM render. Per-trial extension params arrive here.
63
47
  on_load(params) {
64
48
  this._monitoring = false;
65
49
 
66
- // In explicit opt-in mode (autoMonitor: false), only monitor trials
67
- // that provide a trialId in their per-trial extension params.
68
- if (this.params.autoMonitor === false) {
69
- if (!params || !params.trialId) return;
50
+ // Explicit opt-in mode: only monitor trials that pass a trialId.
51
+ if (this.params.autoMonitor === false && !(params && params.trialId)) {
52
+ return;
70
53
  }
71
54
 
72
- // Auto-monitor with exclusions: skip trial types the researcher listed.
73
- // This is useful for excluding instruction screens, fixation crosses, etc.
74
- // Trial type name comes from the plugin's static info.name property.
55
+ // Auto-monitor with exclusions: skip trial types named in the config
56
+ // (instructions, fixation, etc.). Trial type name comes from the plugin's
57
+ // static info.name property.
75
58
  if (this.params.excludeTrialTypes) {
76
- const currentTrial = this.jsPsych.getCurrentTrial();
77
- const typeName = currentTrial.type?.info?.name || '';
59
+ const typeName = this.jsPsych.getCurrentTrial().type?.info?.name || '';
78
60
  if (this.params.excludeTrialTypes.includes(typeName)) return;
79
61
  }
80
62
 
81
- // Start monitoring this trial. We pass through per-trial params so the
82
- // core library knows the trial ID, phase, decoy text, and where to scope
83
- // typing/mouse signals.
84
63
  this._monitoring = true;
85
- // P3: Record performance.now() at trial start so ingest can compute
86
- // trial-relative tab-away timestamps by direct subtraction (no offset
87
- // estimator needed). Captured on the same monotonic clock that
88
- // tabAwayEvents[*].start uses, so the difference is exact.
64
+ // Capture performance.now() at trial start so ingest can compute
65
+ // trial-relative tab-away timestamps by direct subtraction. tabAwayEvents
66
+ // store their `start` on the same monotonic clock, so the difference is
67
+ // exact. Without this anchor, the CLI falls back to a heuristic estimator
68
+ // on session-anchored data, which is less precise.
89
69
  this._trialStart_perfNow = performance.now();
90
70
  const trialIndex = this.jsPsych.getProgress().current_trial_global;
91
71
  this.monitor.startTrial({
@@ -96,23 +76,22 @@ class CyborgHunterExtension {
96
76
  });
97
77
  }
98
78
 
99
- // Called when a trial finishes (after participant response, before data save).
100
- // Returns the integrity report object, which jsPsych merges into trial data
101
- // under the key declared in static info.data (i.e., data.integrity).
102
- on_finish(params) {
79
+ // Called when a trial finishes. Returns the integrity report, which jsPsych
80
+ // merges into the trial's data under the key declared in static info.data
81
+ // (i.e., data.integrity).
82
+ on_finish(_params) {
103
83
  if (!this._monitoring) return {};
104
84
  this._monitoring = false;
105
85
  const report = this.monitor.endTrial();
106
- // P3: Attach the on_load performance.now() so downstream ingest can
107
- // convert session-absolute event timestamps (e.g., tabAwayEvents[*].start)
108
- // to trial-relative ms without heuristic offset estimation.
86
+ // Forward the on_load anchor (see on_load above) so ingest can do exact
87
+ // time arithmetic without re-deriving it.
109
88
  report.trialStart_perfNow = this._trialStart_perfNow;
110
89
  this._trialStart_perfNow = null;
111
90
  return { integrity: report };
112
91
  }
113
92
 
114
- // Called explicitly by the researcher from their initJsPsych({ on_finish })
115
- // callback, BEFORE saving data. Example:
93
+ // Called explicitly by the researcher from initJsPsych({ on_finish }), BEFORE
94
+ // saving data. Example:
116
95
  //
117
96
  // const jsPsych = initJsPsych({
118
97
  // extensions: [{ type: jsPsychCyborgHunter, params: { ... } }],
@@ -125,42 +104,41 @@ class CyborgHunterExtension {
125
104
  // Why this isn't automatic: jsPsych 7 has no on_finish_experiment extension
126
105
  // hook (only initialize / on_start / on_load / on_finish exist). An earlier
127
106
  // version of this wrapper defined on_finish_experiment and silently dropped
128
- // session data because jsPsych never called it.
107
+ // session data because jsPsych never called it. We surface the boundary
108
+ // explicitly so it can't silently regress.
129
109
  //
130
- // Persists the full session report using Option A split attachment:
131
- // - Scalars (counts, booleans, version) go to jsPsych.data.addProperties()
132
- // so they land as columns on EVERY trial row — small, filterable in R/pandas.
133
- // - Arrays and nested objects (sidebarEvents, integrityScore, etc.) go to
134
- // jsPsych.data.addDataToLastTrial() so they're attached ONCE to the final
135
- // trial row — avoids duplicating kilobytes of JSON across every row.
110
+ // Persistence shape (split attachment):
111
+ // - Scalars (counts, booleans, version) → addProperties, so they appear
112
+ // as columns on EVERY trial row. Cheap, queryable from R/pandas.
113
+ // - Arrays + nested objects (sidebarEvents, integrityScore, etc.) →
114
+ // addDataToLastTrial, so they're attached ONCE on the final row.
115
+ // Avoids duplicating kilobytes of identical JSON across every row.
136
116
  //
137
- // After persisting, tears down all listeners and clears session state.
117
+ // After persisting, tears down listeners and clears session state.
138
118
  finalize() {
139
119
  if (!this.monitor) return;
140
120
 
141
121
  try {
142
- // Single call — Task 5A made getSessionReport() the source of truth, so
143
- // this object has both the accumulators AND the scoring summary.
122
+ // getSessionReport() is the source of truth: contains both the
123
+ // accumulators and the scoring summary in one shape.
144
124
  const report = this.monitor.getSessionReport();
145
125
 
146
126
  const {
147
- // Arrays — go to last trial only (no CSV bloat).
127
+ // Arrays — attached once on the last trial only (no CSV bloat).
148
128
  sidebarEvents = [], keyboardShortcuts = [], windowPositions = [],
149
129
  layoutShifts = [], zoomChanges = [], idleGaps = [], extensionInjections = [],
150
130
  tabAwaySums = [], charsPerSec = [], aiExtensionsFound = [],
151
- // Scoring summary — full object to last trial; key scalars also duplicated
152
- // to every trial via addProperties below.
131
+ // Scoring summary — full object on last trial; key scalars below
132
+ // also duplicated to every row via addProperties.
153
133
  hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold,
154
134
  // Scalars.
155
135
  pasteCount = 0, copyCount = 0, dropCount = 0,
156
136
  libraryVersion, config,
157
- // Anything else the monitor adds in the future — goes with the arrays
158
- // so it's captured on the last trial without polluting every row.
137
+ // Future-proof: anything new from the monitor flows into integritySession
138
+ // automatically without polluting the per-row scalar columns.
159
139
  ...extras
160
140
  } = report;
161
141
 
162
- // Scalars: one column per field, duplicated across every trial row.
163
- // Cheap, useful for per-trial filtering and quick exclusion queries.
164
142
  this.jsPsych.data.addProperties({
165
143
  integrityPasteCount: pasteCount,
166
144
  integrityCopyCount: copyCount,
@@ -170,7 +148,6 @@ class CyborgHunterExtension {
170
148
  cyborgHunterVersion: libraryVersion,
171
149
  });
172
150
 
173
- // Arrays + nested objects: attached once, on the final trial row.
174
151
  this.jsPsych.data.addDataToLastTrial({
175
152
  integritySession: {
176
153
  pasteCount, copyCount, dropCount,
@@ -182,17 +159,26 @@ class CyborgHunterExtension {
182
159
  integrityScore: { hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold },
183
160
  });
184
161
  } catch (e) {
185
- // Never let a reporting error crash the experiment's end-of-session path.
162
+ // Don't crash the experiment's end-of-session path — the user's save
163
+ // call (localSave / dispatcher / etc.) MUST still run. But don't fail
164
+ // silently either — drop a marker into the data so analysts can see
165
+ // during ingest that finalize() failed for this participant, plus log
166
+ // to console for local debugging.
186
167
  console.warn('[cyborg-hunter] Failed to save session report:', e);
168
+ try {
169
+ this.jsPsych.data.addProperties({
170
+ cyborgHunterFinalizeError: String(e && e.message ? e.message : e)
171
+ });
172
+ } catch (_) { /* if even addProperties fails, the warn above is all we have */ }
187
173
  }
188
174
 
189
175
  this.monitor.destroy();
190
176
  }
191
177
  }
192
178
 
193
- // Export for both module and IIFE contexts.
194
- // When loaded as a script tag, window.jsPsychCyborgHunter is the class
195
- // that researchers pass to jsPsych's extensions array.
179
+ // Module export AND IIFE-friendly window assignment. The build target is IIFE,
180
+ // so `window.jsPsychCyborgHunter` is what researchers reference in their
181
+ // extensions array. The named export exists for completeness.
196
182
  export { CyborgHunterExtension };
197
183
  if (typeof window !== 'undefined') {
198
184
  window.jsPsychCyborgHunter = CyborgHunterExtension;