compileprofile-spacetests 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.
Files changed (37) hide show
  1. package/CLAUDE.md +114 -114
  2. package/README.md +171 -147
  3. package/dist/cjs/common-interfaces.d.ts +33 -1
  4. package/dist/cjs/gonogo.js +89 -57
  5. package/dist/cjs/nback.d.ts +6 -2
  6. package/dist/cjs/nback.js +132 -2
  7. package/dist/cjs/spatial-cognition.js +0 -2
  8. package/dist/esm/common-interfaces.d.ts +33 -1
  9. package/dist/esm/gonogo.js +89 -57
  10. package/dist/esm/nback.d.ts +6 -2
  11. package/dist/esm/nback.js +128 -2
  12. package/dist/esm/spatial-cognition.js +0 -2
  13. package/example-data/emotions.json +807 -807
  14. package/example-data/gonogo.json +5392 -5392
  15. package/example-data/nback.json +892 -892
  16. package/example-data/participant.json +6 -6
  17. package/example-data/spatial-cognition.json +462 -462
  18. package/jest.config.js +13 -13
  19. package/original/emotion-recognition.R +39 -39
  20. package/original/gonogo.R +32 -32
  21. package/package.json +40 -40
  22. package/src/__tests__/integration/browserInfo.test.ts +16 -16
  23. package/src/__tests__/integration/emotions.test.ts +172 -172
  24. package/src/__tests__/integration/gonogo.test.ts +293 -183
  25. package/src/__tests__/integration/nback-versions.test.ts +182 -0
  26. package/src/__tests__/integration/nback.test.ts +148 -148
  27. package/src/__tests__/integration/spatial-cognition.test.ts +171 -171
  28. package/src/__tests__/test-utils/testData.ts +79 -79
  29. package/src/common-interfaces.ts +124 -78
  30. package/src/emotions.ts +98 -98
  31. package/src/getters.ts +21 -21
  32. package/src/gonogo.ts +149 -127
  33. package/src/index.ts +39 -39
  34. package/src/nback.ts +182 -61
  35. package/src/spatial-cognition.ts +110 -112
  36. package/tsconfig.cjs.json +12 -12
  37. package/tsconfig.esm.json +14 -14
package/src/gonogo.ts CHANGED
@@ -1,127 +1,149 @@
1
- import { GonogoResultsStructure, CommonResults, GonogoSpecialResults } from './common-interfaces';
2
- const BEST_SCORE = 300;
3
- const WORST_SCORE = 5000;
4
-
5
- const gonogo_calculate_results = (data: any[]): GonogoResultsStructure => {
6
- const trials = gonogo_extract_trials(data);
7
- const results = gonogo_results(trials);
8
- return results;
9
- }
10
-
11
- const gonogo_extract_trials = (obj: any[]): any[] => {
12
- // GONOGO_IMAGE_PATH <- "https://unyavwofnmwihnpvkjji.supabase.co/storage/v1/object/public/test-stimuli/gonogo/"
13
- let trials = obj.filter((o: any) => 'image' in o && o.image !== undefined);
14
- trials.forEach((o: any) => {
15
- // image is in format <stimulus>_trans_<number>.png
16
- // "image": "https://unyavwofnmwihnpvkjji.supabase.co/storage/v1/object/public/test-stimuli/gonogo/aliens/astronaut_trans_1.png",
17
- const match = o.image.match(/([^/]+)_(\d+)\.png$/);
18
- if (match) {
19
- o.stimulus = match[0];
20
- o.stimulustype = match[1];
21
- o.image_number = match[2];
22
- } else {
23
- console.warn('Image format did not match expected pattern:', o.image);
24
- return;
25
- }
26
- o.should_go = false;
27
- o.did_go = o.response !== null;
28
- switch (o.block_number) {
29
- case 1:
30
- o.should_go = o.stimulus == "astronaut_trans_1.png";
31
- break;
32
- case 2:
33
- o.should_go = o.stimulus == "alien_trans_1.png";
34
- break;
35
- case 3:
36
- o.should_go = o.stimulus == "rocket_trans_1.png";
37
- break;
38
- case 4:
39
- o.should_go = !(o.stimulus == "rocket_trans_1.png");
40
- break;
41
- default:
42
- o.should_go = false;
43
- break;
44
- }
45
- o.correct = o.should_go == o.did_go;
46
- })
47
- return trials;
48
- }
49
-
50
- // TODO this
51
- const calculate_performance_score = (avg_rt: number, avg_acc: number): number => {
52
- let performance = avg_rt / (Math.pow(avg_acc, 5) + 0.001);
53
- performance = Math.min(Math.max(performance, BEST_SCORE), WORST_SCORE);
54
- const performance_score = (1 - ((performance - BEST_SCORE) / (WORST_SCORE - BEST_SCORE))) * 100;
55
- return performance_score;
56
- }
57
-
58
- const gonogo_results = (data: any[]): GonogoResultsStructure => {
59
- // throw error if obj is empty
60
- if (data.length === 0) {
61
- throw new Error("No trials found");
62
- }
63
-
64
- const correct_trials = data.filter((o: any) => o.correct === true);
65
- const incorrect_trials = data.filter((o: any) => o.correct === false);
66
- const missing_trials = []; // technically not possible in GNG, as no response is just incorrect
67
-
68
- const go_trials = data.filter((o: any) => o.should_go === true);
69
- const nogo_trials = data.filter((o: any) => o.should_go === false);
70
-
71
- const total_rt = data.reduce((a, b) => a + (b.rt || 0), 0);
72
- const correct_rt = correct_trials.reduce((a, b) => a + (b.rt || 0), 0);
73
- const incorrect_rt = incorrect_trials.reduce((a, b) => a + (b.rt || 0), 0);
74
-
75
- const responded_trials = data.filter((o: any) => o.response !== null);
76
- const correct_responded_trials = responded_trials.filter((o: any) => o.correct === true);
77
- const incorrect_responded_trials = responded_trials.filter((o: any) => o.correct === false);
78
-
79
- const avg_rt = responded_trials.length > 0 ? total_rt / responded_trials.length : 0;
80
- const avg_acc = correct_trials.length / data.length;
81
-
82
- // Calculate performance score using the existing formula
83
- const performance_score = calculate_performance_score(avg_rt, avg_acc);
84
-
85
- const common: CommonResults = {
86
- n_trials: data.length,
87
- n_correct_trials: correct_trials.length,
88
- n_incorrect_trials: incorrect_trials.length,
89
- avg_rt_total: avg_rt,
90
- avg_rt_correct: correct_trials.length > 0 ? correct_rt / correct_responded_trials.length : 0,
91
- avg_rt_incorrect: incorrect_trials.length > 0 ? incorrect_rt / incorrect_responded_trials.length : 0,
92
- performance_score
93
- };
94
-
95
- // Group by block for special results
96
- const by_block = Object.values(data.reduce((acc: any, trial: any) => {
97
- const key = trial.block_number || 'unknown';
98
- if (!acc[key]) acc[key] = { block: key, trials: [], avg_rt: 0, avg_acc: 0 };
99
- acc[key].trials.push(trial);
100
- return acc;
101
- }, {})).map((group: any) => {
102
- group.avg_rt = group.trials.reduce((a: number, b: any) => a + (b.rt || 0), 0) / group.trials.length;
103
- group.avg_acc = group.trials.reduce((a: number, b: any) => a + (b.correct ? 1 : 0), 0) / group.trials.length;
104
- return group;
105
- });
106
-
107
- const special: GonogoSpecialResults = {
108
- by_block,
109
- go_trials: {
110
- n: go_trials.length,
111
- avg_rt: go_trials.length > 0 ? go_trials.reduce((a, b) => a + (b.rt || 0), 0) / go_trials.length : 0,
112
- avg_acc: go_trials.length > 0 ? go_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) / go_trials.length : 0,
113
- },
114
- nogo_trials: {
115
- n: nogo_trials.length,
116
- avg_rt: nogo_trials.length > 0 ? nogo_trials.reduce((a, b) => a + (b.rt || 0), 0) / nogo_trials.length : 0,
117
- avg_acc: nogo_trials.length > 0 ? nogo_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) / nogo_trials.length : 0,
118
- }
119
- };
120
-
121
- return {
122
- common,
123
- special
124
- };
125
- }
126
-
127
- export { GonogoResultsStructure, gonogo_calculate_results, gonogo_extract_trials, gonogo_results };
1
+ import { GonogoResultsStructure, CommonResults, GonogoSpecialResults } from './common-interfaces';
2
+ const BEST_SCORE = 300;
3
+ const WORST_SCORE = 5000;
4
+
5
+ const gonogo_calculate_results = (data: any[]): GonogoResultsStructure => {
6
+ const trials = gonogo_extract_trials(data);
7
+ const results = gonogo_results(trials);
8
+ return results;
9
+ }
10
+
11
+ // Which stimulus is "go" in each block of the runner:
12
+ // 1 astronaut, 2 alien, 3 rocket, 4 anything but the rocket (the UFOs).
13
+ const should_go_for = (block_number: number, stimulus: string): boolean => {
14
+ switch (block_number) {
15
+ case 1: return stimulus == "astronaut_trans_1.png";
16
+ case 2: return stimulus == "alien_trans_1.png";
17
+ case 3: return stimulus == "rocket_trans_1.png";
18
+ case 4: return stimulus != "rocket_trans_1.png";
19
+ default: return false;
20
+ }
21
+ }
22
+
23
+ const gonogo_extract_trials = (obj: any[]): any[] => {
24
+ const trials: any[] = [];
25
+ obj.filter((o: any) => 'image' in o && o.image !== undefined && o.image !== null).forEach((o: any) => {
26
+ // image is in format <stimulus>_trans_<number>.png, e.g.
27
+ // ".../test-stimuli/gonogo/aliens/astronaut_trans_1.png"
28
+ const match = String(o.image).match(/([^/]+)_(\d+)\.png$/);
29
+ if (!match) {
30
+ // Without a recognisable stimulus the trial cannot be scored, so leave it out
31
+ // instead of counting it as neither correct nor incorrect.
32
+ console.warn('Image format did not match expected pattern:', o.image);
33
+ return;
34
+ }
35
+ // Copy, so the caller's data is not modified.
36
+ const trial = { ...o };
37
+ trial.stimulus = match[0];
38
+ trial.stimulustype = match[1];
39
+ trial.image_number = match[2];
40
+ // Prefer a go/no-go flag recorded by the runner; otherwise derive it from block and image.
41
+ trial.should_go = typeof o.should_go === 'boolean' ? o.should_go : should_go_for(o.block_number, trial.stimulus);
42
+ trial.did_go = o.response !== null && o.response !== undefined;
43
+ trial.correct = trial.should_go == trial.did_go;
44
+ trials.push(trial);
45
+ });
46
+ return trials;
47
+ }
48
+
49
+ const has_rt = (o: any): boolean => o.did_go === true && typeof o.rt === 'number' && !isNaN(o.rt);
50
+
51
+ // Mean of the values; 0 for an empty list, so callers always get a number.
52
+ const mean = (values: number[]): number => values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
53
+
54
+ // Mean RT over the trials that have a response; trials without one have no RT and are not averaged in as 0.
55
+ const mean_rt = (trials: any[]): number => mean(trials.filter(has_rt).map((o: any) => o.rt));
56
+
57
+ const proportion = (trials: any[], pred: (o: any) => boolean): number =>
58
+ trials.length > 0 ? trials.filter(pred).length / trials.length : 0;
59
+
60
+ // Hit rate and correct-rejection rate weighted equally. When a set has no go (or no no-go)
61
+ // trials, the other rate is used alone.
62
+ const balanced_accuracy = (go_trials: any[], nogo_trials: any[]): number => {
63
+ const rates: number[] = [];
64
+ if (go_trials.length > 0) rates.push(proportion(go_trials, (o) => o.correct === true));
65
+ if (nogo_trials.length > 0) rates.push(proportion(nogo_trials, (o) => o.correct === true));
66
+ return mean(rates);
67
+ }
68
+
69
+ // Score 0-100 from the hit RT and the balanced accuracy: RT / (acc^5), clamped to 300-5000 ms.
70
+ // Without any hit there is no RT to score, so the score is 0. (Previously an RT of 0 was
71
+ // clamped to the best value, so a participant who never responded scored 100.)
72
+ const calculate_performance_score = (hit_rt: number, acc: number): number => {
73
+ if (!(hit_rt > 0)) {
74
+ return 0;
75
+ }
76
+ let performance = hit_rt / (Math.pow(acc, 5) + 0.001);
77
+ performance = Math.min(Math.max(performance, BEST_SCORE), WORST_SCORE);
78
+ const performance_score = (1 - ((performance - BEST_SCORE) / (WORST_SCORE - BEST_SCORE))) * 100;
79
+ return performance_score;
80
+ }
81
+
82
+ const gonogo_results = (data: any[]): GonogoResultsStructure => {
83
+ // throw error if obj is empty
84
+ if (data.length === 0) {
85
+ throw new Error("No trials found");
86
+ }
87
+
88
+ const correct_trials = data.filter((o: any) => o.correct === true);
89
+ const incorrect_trials = data.filter((o: any) => o.correct === false);
90
+
91
+ const go_trials = data.filter((o: any) => o.should_go === true);
92
+ const nogo_trials = data.filter((o: any) => o.should_go === false);
93
+
94
+ // A response is correct on go trials (hit) and incorrect on no-go trials (false alarm).
95
+ // Correct no-go trials and missed go trials have no response, so no RT.
96
+ const hit_rt = mean_rt(go_trials);
97
+ const balanced_acc = balanced_accuracy(go_trials, nogo_trials);
98
+
99
+ const common: CommonResults = {
100
+ n_trials: data.length,
101
+ n_correct_trials: correct_trials.length,
102
+ n_incorrect_trials: incorrect_trials.length,
103
+ avg_rt_total: mean_rt(data),
104
+ avg_rt_correct: mean_rt(correct_trials),
105
+ avg_rt_incorrect: mean_rt(incorrect_trials),
106
+ performance_score: calculate_performance_score(hit_rt, balanced_acc)
107
+ };
108
+
109
+ // Group by block for special results
110
+ const by_block = Object.values(data.reduce((acc: any, trial: any) => {
111
+ const key = trial.block_number || 'unknown';
112
+ if (!acc[key]) acc[key] = { block: key, trials: [] };
113
+ acc[key].trials.push(trial);
114
+ return acc;
115
+ }, {})).map((group: any) => {
116
+ const block_go = group.trials.filter((o: any) => o.should_go === true);
117
+ const block_nogo = group.trials.filter((o: any) => o.should_go === false);
118
+ group.avg_rt = mean_rt(block_go);
119
+ group.avg_acc = proportion(group.trials, (o) => o.correct === true);
120
+ group.hit_rate = proportion(block_go, (o) => o.correct === true);
121
+ group.correct_rejection_rate = proportion(block_nogo, (o) => o.correct === true);
122
+ group.balanced_acc = balanced_accuracy(block_go, block_nogo);
123
+ return group;
124
+ });
125
+
126
+ const special: GonogoSpecialResults = {
127
+ balanced_acc,
128
+ by_block,
129
+ go_trials: {
130
+ n: go_trials.length,
131
+ avg_rt: hit_rt,
132
+ avg_acc: proportion(go_trials, (o) => o.correct === true),
133
+ n_omissions: go_trials.filter((o: any) => o.did_go === false).length,
134
+ },
135
+ nogo_trials: {
136
+ n: nogo_trials.length,
137
+ avg_rt: mean_rt(nogo_trials),
138
+ avg_acc: proportion(nogo_trials, (o) => o.correct === true),
139
+ n_commissions: nogo_trials.filter((o: any) => o.did_go === true).length,
140
+ }
141
+ };
142
+
143
+ return {
144
+ common,
145
+ special
146
+ };
147
+ }
148
+
149
+ export { GonogoResultsStructure, gonogo_calculate_results, gonogo_extract_trials, gonogo_results };
package/src/index.ts CHANGED
@@ -1,39 +1,39 @@
1
- import { nback_calculate_results } from './nback';
2
- import { spatial_calculate_results } from './spatial-cognition';
3
- import { gonogo_calculate_results } from './gonogo';
4
- import { emotion_calculate_results } from './emotions';
5
- import {
6
- NbackResultsStructure,
7
- SpatialResultsStructure,
8
- GonogoResultsStructure,
9
- EmotionResultsStructure,
10
- CommonResults
11
- } from './common-interfaces';
12
-
13
- const NbackResults = (obj: any[]): NbackResultsStructure => {
14
- return nback_calculate_results(obj);
15
- }
16
-
17
- const SpatialCognitionResults = (obj: any[]): SpatialResultsStructure => {
18
- return spatial_calculate_results(obj);
19
- }
20
-
21
- const GonogoResults = (obj: any[]): GonogoResultsStructure => {
22
- return gonogo_calculate_results(obj);
23
- }
24
-
25
- const EmotionResults = (obj: any[]): EmotionResultsStructure => {
26
- return emotion_calculate_results(obj);
27
- }
28
-
29
- export {
30
- NbackResults,
31
- SpatialCognitionResults,
32
- EmotionResults,
33
- GonogoResults,
34
- NbackResultsStructure,
35
- SpatialResultsStructure,
36
- GonogoResultsStructure,
37
- EmotionResultsStructure,
38
- CommonResults
39
- };
1
+ import { nback_calculate_results } from './nback';
2
+ import { spatial_calculate_results } from './spatial-cognition';
3
+ import { gonogo_calculate_results } from './gonogo';
4
+ import { emotion_calculate_results } from './emotions';
5
+ import {
6
+ NbackResultsStructure,
7
+ SpatialResultsStructure,
8
+ GonogoResultsStructure,
9
+ EmotionResultsStructure,
10
+ CommonResults
11
+ } from './common-interfaces';
12
+
13
+ const NbackResults = (obj: any[]): NbackResultsStructure => {
14
+ return nback_calculate_results(obj);
15
+ }
16
+
17
+ const SpatialCognitionResults = (obj: any[]): SpatialResultsStructure => {
18
+ return spatial_calculate_results(obj);
19
+ }
20
+
21
+ const GonogoResults = (obj: any[]): GonogoResultsStructure => {
22
+ return gonogo_calculate_results(obj);
23
+ }
24
+
25
+ const EmotionResults = (obj: any[]): EmotionResultsStructure => {
26
+ return emotion_calculate_results(obj);
27
+ }
28
+
29
+ export {
30
+ NbackResults,
31
+ SpatialCognitionResults,
32
+ EmotionResults,
33
+ GonogoResults,
34
+ NbackResultsStructure,
35
+ SpatialResultsStructure,
36
+ GonogoResultsStructure,
37
+ EmotionResultsStructure,
38
+ CommonResults
39
+ };
package/src/nback.ts CHANGED
@@ -1,61 +1,182 @@
1
- import { NbackResultsStructure, CommonResults, NbackSpecialResults } from './common-interfaces';
2
-
3
- // from the passed json extract those objects that have field is_target
4
- const nback_calculate_results = (obj: any[]): NbackResultsStructure => {
5
- const trials = nback_extract_trials(obj);
6
- return nback_results(trials);
7
- }
8
-
9
- const nback_extract_trials = (obj: any[]): any[] => {
10
- const trials = obj.filter((o: any) => 'is_target' in o);
11
- return trials;
12
- }
13
-
14
- const nback_results = (trials: any[]): NbackResultsStructure => {
15
- const target_trials = trials.filter((o: any) => o.is_target);
16
- const non_target_trials = trials.filter((o: any) => !o.is_target);
17
- const correct_trials = trials.filter((o: any) => o.correct === true);
18
- const incorrect_trials = trials.filter((o: any) => o.correct === false);
19
-
20
- const total_rt = trials.reduce((a, b) => a + (b.rt || 0), 0);
21
- const correct_rt = correct_trials.reduce((a, b) => a + (b.rt || 0), 0);
22
- const incorrect_rt = incorrect_trials.reduce((a, b) => a + (b.rt || 0), 0);
23
-
24
- const responded_trials = trials.filter((o: any) => o.response !== null);
25
- const correct_responded_trials = responded_trials.filter((o: any) => o.correct === true);
26
- const incorrect_responded_trials = responded_trials.filter((o: any) => o.correct === false);
27
-
28
- const common: CommonResults = {
29
- n_trials: trials.length,
30
- n_correct_trials: correct_trials.length,
31
- n_incorrect_trials: incorrect_trials.length,
32
- avg_rt_total: trials.length > 0 ? total_rt / responded_trials.length : 0,
33
- avg_rt_correct: correct_trials.length > 0 ? correct_rt / correct_responded_trials.length : 0,
34
- avg_rt_incorrect: incorrect_trials.length > 0 ? incorrect_rt / incorrect_responded_trials.length : 0,
35
- performance_score: trials.length > 0 ? correct_trials.length * 100 / trials.length : 0
36
- };
37
-
38
- const special: NbackSpecialResults = {
39
- target_trials: {
40
- n: target_trials.length,
41
- avg_rt: target_trials.length > 0 ? target_trials.reduce((a, b) => a + (b.rt || 0), 0) / target_trials.length : 0,
42
- avg_acc: target_trials.length > 0 ? target_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) * 100 / target_trials.length : 0,
43
- },
44
- non_target_trials: {
45
- n: non_target_trials.length,
46
- avg_rt: non_target_trials.length > 0 ? non_target_trials.reduce((a, b) => a + (b.rt || 0), 0) / non_target_trials.length : 0,
47
- avg_acc: non_target_trials.length > 0 ? non_target_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) * 100 / non_target_trials.length : 0,
48
- }
49
- };
50
-
51
- const results: NbackResultsStructure = {
52
- common,
53
- special
54
- };
55
-
56
- console.log(results);
57
- return results;
58
- }
59
-
60
- export { nback_extract_trials, nback_results,
61
- nback_calculate_results, NbackResultsStructure };
1
+ import { NbackResultsStructure, CommonResults, NbackSpecialResults,
2
+ NbackGroupResults, NbackBlockResults, NbackLevelResults } from './common-interfaces';
3
+
4
+ // from the passed json extract those objects that have field is_target
5
+ // Version 1 trials have no n_back field and get exactly the original result. Version 2 trials
6
+ // (n_back, block, block_index on each trial) also get special.by_block and special.by_nback.
7
+ const nback_calculate_results = (obj: any[]): NbackResultsStructure => {
8
+ const trials = nback_extract_trials(obj);
9
+ const results = nback_results(trials);
10
+ if (nback_is_versioned(trials)) {
11
+ results.special.by_block = nback_results_by_block(trials);
12
+ results.special.by_nback = nback_results_by_nback(trials);
13
+ }
14
+ return results;
15
+ }
16
+
17
+ const nback_is_versioned = (trials: any[]): boolean =>
18
+ trials.some((t: any) => t.n_back !== undefined && t.n_back !== null);
19
+
20
+ const nback_extract_trials = (obj: any[]): any[] => {
21
+ const trials = obj.filter((o: any) => 'is_target' in o);
22
+ return trials;
23
+ }
24
+
25
+ const nback_results = (trials: any[]): NbackResultsStructure => {
26
+ const target_trials = trials.filter((o: any) => o.is_target);
27
+ const non_target_trials = trials.filter((o: any) => !o.is_target);
28
+ const correct_trials = trials.filter((o: any) => o.correct === true);
29
+ const incorrect_trials = trials.filter((o: any) => o.correct === false);
30
+
31
+ const total_rt = trials.reduce((a, b) => a + (b.rt || 0), 0);
32
+ const correct_rt = correct_trials.reduce((a, b) => a + (b.rt || 0), 0);
33
+ const incorrect_rt = incorrect_trials.reduce((a, b) => a + (b.rt || 0), 0);
34
+
35
+ const responded_trials = trials.filter((o: any) => o.response !== null);
36
+ const correct_responded_trials = responded_trials.filter((o: any) => o.correct === true);
37
+ const incorrect_responded_trials = responded_trials.filter((o: any) => o.correct === false);
38
+
39
+ const common: CommonResults = {
40
+ n_trials: trials.length,
41
+ n_correct_trials: correct_trials.length,
42
+ n_incorrect_trials: incorrect_trials.length,
43
+ avg_rt_total: trials.length > 0 ? total_rt / responded_trials.length : 0,
44
+ avg_rt_correct: correct_trials.length > 0 ? correct_rt / correct_responded_trials.length : 0,
45
+ avg_rt_incorrect: incorrect_trials.length > 0 ? incorrect_rt / incorrect_responded_trials.length : 0,
46
+ performance_score: trials.length > 0 ? correct_trials.length * 100 / trials.length : 0
47
+ };
48
+
49
+ const special: NbackSpecialResults = {
50
+ target_trials: {
51
+ n: target_trials.length,
52
+ avg_rt: target_trials.length > 0 ? target_trials.reduce((a, b) => a + (b.rt || 0), 0) / target_trials.length : 0,
53
+ avg_acc: target_trials.length > 0 ? target_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) * 100 / target_trials.length : 0,
54
+ },
55
+ non_target_trials: {
56
+ n: non_target_trials.length,
57
+ avg_rt: non_target_trials.length > 0 ? non_target_trials.reduce((a, b) => a + (b.rt || 0), 0) / non_target_trials.length : 0,
58
+ avg_acc: non_target_trials.length > 0 ? non_target_trials.reduce((a, b) => a + (b.correct ? 1 : 0), 0) * 100 / non_target_trials.length : 0,
59
+ }
60
+ };
61
+
62
+ const results: NbackResultsStructure = {
63
+ common,
64
+ special
65
+ };
66
+
67
+ console.log(results);
68
+ return results;
69
+ }
70
+
71
+ // Standard normal CDF (Marsaglia 2004, Taylor series; absolute error ~1e-15 for |x| < 8)
72
+ const normal_cdf = (x: number): number => {
73
+ let sum = x, term = x, prev = 0;
74
+ const x2 = x * x;
75
+ for (let i = 3; sum !== prev; i += 2) {
76
+ prev = sum;
77
+ term *= x2 / i;
78
+ sum = prev + term;
79
+ }
80
+ return 0.5 + sum * Math.exp(-0.5 * x2 - 0.91893853320467274178);
81
+ }
82
+
83
+ // Inverse of the standard normal CDF: Acklam's rational approximation, polished with one
84
+ // Halley step against normal_cdf, as Acklam recommends. Accurate to ~1e-12 for 1e-10 < p < 1 - 1e-10.
85
+ const inverse_normal = (p: number): number => {
86
+ if (p <= 0) return -Infinity;
87
+ if (p >= 1) return Infinity;
88
+ const x = acklam_inverse_normal(p);
89
+ const e = normal_cdf(x) - p;
90
+ const u = e * Math.sqrt(2 * Math.PI) * Math.exp(x * x / 2);
91
+ return x - u / (1 + x * u / 2);
92
+ }
93
+
94
+ const acklam_inverse_normal = (p: number): number => {
95
+ const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
96
+ 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
97
+ const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
98
+ 6.680131188771972e+01, -1.328068155288572e+01];
99
+ const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
100
+ -2.549671010322563e+00, 4.374664141464968e+00, 2.938163982698783e+00];
101
+ const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
102
+ 3.754408661907416e+00];
103
+ const p_low = 0.02425;
104
+ if (p < p_low) {
105
+ const q = Math.sqrt(-2 * Math.log(p));
106
+ return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
107
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
108
+ }
109
+ if (p > 1 - p_low) return -acklam_inverse_normal(1 - p);
110
+ const q = p - 0.5;
111
+ const r = q * q;
112
+ return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
113
+ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
114
+ }
115
+
116
+ const mean_or_null = (xs: number[]): number | null =>
117
+ xs.length > 0 ? xs.reduce((s, x) => s + x, 0) / xs.length : null;
118
+
119
+ const responded = (t: any): boolean => t.response !== null && t.response !== undefined;
120
+
121
+ // d' uses the log-linear correction (Hautus 1995), so it stays finite at 0 % or 100 % rates
122
+ const nback_group_results = (trials: any[]): NbackGroupResults => {
123
+ const targets = trials.filter((t: any) => t.is_target);
124
+ const non_targets = trials.filter((t: any) => !t.is_target);
125
+ const hits = targets.filter(responded);
126
+ const false_alarms = non_targets.filter(responded);
127
+ const lures = non_targets.filter((t: any) => t.is_lure === true);
128
+ const lure_false_alarms = lures.filter(responded).length;
129
+ const n_correct = trials.filter((t: any) => t.correct === true).length;
130
+ const rts = (ts: any[]) => ts.map((t: any) => t.rt).filter((rt: any) => typeof rt === 'number');
131
+
132
+ return {
133
+ n_trials: trials.length,
134
+ n_correct_trials: n_correct,
135
+ performance_score: trials.length > 0 ? n_correct * 100 / trials.length : 0,
136
+ n_targets: targets.length,
137
+ n_non_targets: non_targets.length,
138
+ hits: hits.length,
139
+ misses: targets.length - hits.length,
140
+ false_alarms: false_alarms.length,
141
+ correct_rejections: non_targets.length - false_alarms.length,
142
+ hit_rate: targets.length > 0 ? hits.length / targets.length : null,
143
+ false_alarm_rate: non_targets.length > 0 ? false_alarms.length / non_targets.length : null,
144
+ d_prime: targets.length > 0 && non_targets.length > 0
145
+ ? inverse_normal((hits.length + 0.5) / (targets.length + 1))
146
+ - inverse_normal((false_alarms.length + 0.5) / (non_targets.length + 1))
147
+ : null,
148
+ n_lures: lures.length,
149
+ lure_false_alarms,
150
+ lure_false_alarm_rate: lures.length > 0 ? lure_false_alarms / lures.length : null,
151
+ avg_rt_hits: mean_or_null(rts(hits)),
152
+ avg_rt_false_alarms: mean_or_null(rts(false_alarms)),
153
+ };
154
+ }
155
+
156
+ const nback_results_by_block = (trials: any[]): NbackBlockResults[] => {
157
+ const blocks = new Map<number, any[]>();
158
+ for (const t of trials) {
159
+ if (!blocks.has(t.block_index)) blocks.set(t.block_index, []);
160
+ blocks.get(t.block_index)!.push(t);
161
+ }
162
+ return Array.from(blocks.entries())
163
+ .sort(([a], [b]) => a - b)
164
+ .map(([block_index, ts]) => ({
165
+ block: ts[0].block,
166
+ block_index,
167
+ n_back: ts[0].n_back,
168
+ ...nback_group_results(ts),
169
+ }));
170
+ }
171
+
172
+ const nback_results_by_nback = (trials: any[]): NbackLevelResults[] => {
173
+ const levels = Array.from(new Set(trials.map((t: any) => t.n_back as number))).sort((a, b) => a - b);
174
+ return levels.map(n_back => ({
175
+ n_back,
176
+ ...nback_group_results(trials.filter((t: any) => t.n_back === n_back)),
177
+ }));
178
+ }
179
+
180
+ export { nback_extract_trials, nback_results, nback_calculate_results,
181
+ nback_group_results, nback_results_by_block, nback_results_by_nback,
182
+ inverse_normal, NbackResultsStructure };