From 8130bda76aee3bbf89fcabfbc384743f47307bdb Mon Sep 17 00:00:00 2001 From: Zach Watson Date: Fri, 20 Feb 2026 13:46:55 -0800 Subject: [PATCH 1/3] refactor math CAT to use downex column of corpus rather than block_index --- task-launcher/src/tasks/math/timeline.ts | 155 ++++++++++-------- .../src/tasks/mental-rotation/timeline.ts | 2 +- .../same-different-selection/catTimeline.ts | 8 +- .../same-different-selection/timeline.ts | 3 - .../src/tasks/shared/helpers/getStimulus.ts | 5 + .../src/tasks/shared/helpers/prepareCat.ts | 59 +++++-- 6 files changed, 136 insertions(+), 96 deletions(-) diff --git a/task-launcher/src/tasks/math/timeline.ts b/task-launcher/src/tasks/math/timeline.ts index b87629e0b..d65b9c124 100644 --- a/task-launcher/src/tasks/math/timeline.ts +++ b/task-launcher/src/tasks/math/timeline.ts @@ -23,6 +23,7 @@ import { taskFinished, practiceTransition, feedback, + setupDownex, } from '../shared/trials'; import { getLayoutConfig } from './helpers/config'; import { taskStore } from '../../taskStore'; @@ -56,23 +57,17 @@ export default function buildMathTimeline(config: Record, mediaAsse const timeline = [preloadTrials, initialTimeline]; let corpus: StimulusType[] = taskStore().corpora.stimulus; + const downexCorpus: StimulusType[] = taskStore().corpora.downex; const translations: Record = taskStore().translations; const validationErrorMap: Record = {}; const { runCat, heavyInstructions } = taskStore(); - // block 3 is only for younger kids - if (!runCat) { - corpus = corpus.filter((trial) => { - return heavyInstructions ? trial.block_index === '3' : trial.block_index !== '3'; - }); - } - taskStore('totalTrials', corpus.length); const layoutConfigMap: Record = {}; let i = 0; - for (const c of corpus) { + for (const c of [...downexCorpus, ...corpus]) { const { itemConfig, errorMessages } = getLayoutConfig(c, translations, mediaAssets, i); layoutConfigMap[c.itemId] = itemConfig; if (errorMessages.length) { @@ -217,79 +212,96 @@ export default function buildMathTimeline(config: Record, mediaAsse if (runCat) { // puts the CAT portion of the corpus into taskStore and removes instructions - const fullCorpus = prepareCorpus(corpus); - // until younger-kid version of math is implemented, combine heavy/light instructions - const allInstructionPractice = fullCorpus.ipLight.concat(fullCorpus.ipHeavy); - const instructions = allInstructionPractice.filter((trial) => trial.trialType == 'instructions'); - let practice = allInstructionPractice.filter((trial) => trial.assessmentStage == 'practice_response'); - - let allBlocks: StimulusType[][] = prepareMultiBlockCat(taskStore().corpora.stimulus); - let downexBlock = allBlocks[3]; - - // remove items from first block that are already in subsequent blocks - const nonDownexIds: string[] = []; - allBlocks - .slice(0, -1) - .flat() - .map((trial) => nonDownexIds.push(trial.itemId as string)); - - downexBlock = downexBlock.filter((trial: StimulusType) => { - return !nonDownexIds.includes(trial.itemId as string); - }); - - // filter practice trials to only include appropriate trial types if downward extension - const excludedDownexPracticeTypes = [ - 'Addition', - 'Number Comparison', - 'Number Identification', - 'Counting', - 'Counting AFC', - ]; - practice = practice.filter( - (trial) => !(trial.block_index === '3' && excludedDownexPracticeTypes.includes(trial.trialType)), - ); - - // move downex block to the beginning - allBlocks = [downexBlock, ...allBlocks.slice(0, 3)]; + const allCorpusParts = prepareCorpus(corpus, true, downexCorpus); + const olderKidInstructionPractice: StimulusType[] = allCorpusParts.ipLight; + const olderKidInstructions: StimulusType[] = olderKidInstructionPractice.filter((trial: StimulusType) => trial.trialType == 'instructions'); + let olderKidPractice: StimulusType[] = olderKidInstructionPractice.filter((trial: StimulusType) => trial.assessmentStage == 'practice_response'); + + let olderKidBlocks: StimulusType[][] = prepareMultiBlockCat(taskStore().corpora.stimulus); - const newCorpora = { - practice: taskStore().corpora.practice, - stimulus: allBlocks, - }; - taskStore('corpora', newCorpora); // puts all blocks into taskStore taskStore('totalTestTrials', 0); // add to this while building out each block - const numOfBlocks = allBlocks.length; - const trialProportionsPerBlock = [2, 2, 3, 3]; // divide by these numbers to get trials per block - for (let i = heavyInstructions ? 0 : 1; i < numOfBlocks; i++) { - // skip first block if not heavyInstructions + // don't repeat instructions + const usedIds: string[] = []; + + // first add downex trials to the timeline + if (heavyInstructions) { + const downexInstructionPractice: StimulusType[] = allCorpusParts.ipHeavy; + const downexInstructions: StimulusType[] = downexInstructionPractice.filter((trial) => trial.trialType == 'instructions'); + let downexPractice: StimulusType[] = downexInstructionPractice.filter((trial) => trial.assessmentStage == 'practice_response'); + + let downexBlock: StimulusType[] = allCorpusParts.downexCat; + + // remove items from first block that are already in subsequent blocks + const nonDownexIds: string[] = []; + olderKidBlocks.flat().map((trial) => nonDownexIds.push(trial.itemId as string)); + + downexBlock = downexBlock.filter((trial: StimulusType) => { + return !nonDownexIds.includes(trial.itemId as string); + }); + + // filter practice trials to only include appropriate trial types if downward extension + const excludedDownexPracticeTypes = [ + 'Addition', + 'Number Comparison', + 'Number Identification', + 'Counting', + 'Counting AFC', + ]; + + downexPractice = downexPractice.filter( + (trial) => !excludedDownexPracticeTypes.includes(trial.trialType), + ); + + const allowedIds = ['math-instructions1-heavy', 'math-intro1-heavy']; + + downexInstructions.forEach((trial) => { + if (allowedIds.includes(trial.itemId)) { + timeline.push({ ...fixationOnly, stimulus: '' }); + timeline.push(afcStimulusTemplate(trialConfig, trial)); + } + }); + + downexPractice.forEach((trial) => { + timeline.push({ ...fixationOnly, stimulus: '' }); + timeline.push(stimulusBlock(trial)); + }); + + timeline.push(practiceTransition()); + + const numOfTrials = Math.floor(downexBlock.length / 2); + taskStore.transact('totalTestTrials', (oldVal: number) => (oldVal += numOfTrials)); + for (let j = 0; j < numOfTrials; j++) { + timeline.push({ ...setupDownex, stimulus: '' }); // select only from the current block + timeline.push(stimulusBlock()); + } + } + + const numOfBlocks = olderKidBlocks.length; + const trialProportionsPerBlock = [2, 3, 3]; // divide by these numbers to get trials per block + for (let i = 0; i < numOfBlocks; i++) { // push in block-specific instructions - let usedIDs: string[] = []; - const blockInstructions = instructions.filter((trial) => { - const trialBlock = trial.block_index === '3' ? 0 : Number(trial.block_index) + 1; + const blockInstructions = olderKidInstructions.filter((trial) => { let allowedIDs: string[]; // CAT only uses particular instructions from corpus switch (i) { case 0: - allowedIDs = ['math-instructions1-heavy', 'math-intro1-heavy']; - break; - case 1: allowedIDs = heavyInstructions ? ['math-intro2'] : ['math-instructions1', 'math-intro1']; break; - case 2: + case 1: allowedIDs = ['math-intro2']; break; - case 3: + case 2: allowedIDs = ['math-intro2', 'number-line-instruct1']; break; default: allowedIDs = []; } - const include = trialBlock === i && allowedIDs.includes(trial.itemId) && !usedIDs.includes(trial.itemId); + const include = allowedIDs.includes(trial.itemId) && !usedIds.includes(trial.itemId); if (include) { - usedIDs.push(trial.itemId); + usedIds.push(trial.itemId); } return include; @@ -301,10 +313,10 @@ export default function buildMathTimeline(config: Record, mediaAsse }); // push in block-specific practice trials - const blockPractice = practice.filter((trial) => { - const trialBlock = trial.block_index === '3' ? 0 : Number(trial.block_index) + 1; - return trialBlock === i; + const blockPractice = olderKidPractice.filter((trial) => { + return i === Number(trial.block_index); }); + blockPractice.forEach((trial) => { timeline.push({ ...fixationOnly, stimulus: '' }); timeline.push(stimulusBlock(trial)); @@ -315,7 +327,7 @@ export default function buildMathTimeline(config: Record, mediaAsse }); // final slider block - if (i === 3) { + if (i === 2) { timeline.push(repeatSliderPracticeBlock()); } @@ -323,20 +335,19 @@ export default function buildMathTimeline(config: Record, mediaAsse timeline.push(practiceTransition()); // push in random items at start of first block (after practice trials) - if (i === 1) { - fullCorpus.start.forEach((trial) => timeline.push(stimulusBlock(trial))); + if (i === 0) { + allCorpusParts.start.forEach((trial) => timeline.push(stimulusBlock(trial))); } - const numOfTrials = Math.floor(allBlocks[i].length / trialProportionsPerBlock[i]); + const numOfTrials = Math.floor(olderKidBlocks[i].length / trialProportionsPerBlock[i]); taskStore.transact('totalTestTrials', (oldVal: number) => (oldVal += numOfTrials)); for (let j = 0; j < numOfTrials; j++) { timeline.push({ ...setupStimulusFromBlock(i), stimulus: '' }); // select only from the current block timeline.push(stimulusBlock()); } - fullCorpus.unnormed.forEach((trial) => { - const trialBlock = trial.block_index === '3' ? 0 : Number(trial.block_index) + 1; - if (trialBlock === i) { + allCorpusParts.unnormed.forEach((trial) => { + if (i === Number(trial.block_index)) { timeline.push({ ...fixationOnly, stimulus: '' }); timeline.push(stimulusBlock(trial)); } @@ -349,7 +360,7 @@ export default function buildMathTimeline(config: Record, mediaAsse corpus.forEach((trial) => (trial.difficulty = NaN)); const newCorpora = { - practice: taskStore().corpora.practice, + downex: taskStore().corpora.downex, stimulus: corpus, }; taskStore('corpora', newCorpora); diff --git a/task-launcher/src/tasks/mental-rotation/timeline.ts b/task-launcher/src/tasks/mental-rotation/timeline.ts index 85e0e136b..094596f7c 100644 --- a/task-launcher/src/tasks/mental-rotation/timeline.ts +++ b/task-launcher/src/tasks/mental-rotation/timeline.ts @@ -146,7 +146,7 @@ export default function buildMentalRotationTimeline(config: Record, ); taskStore('corpora', { - practice: taskStore().corpora.practice, + downex: taskStore().corpora.downex, stimulus: corpus, }); diff --git a/task-launcher/src/tasks/same-different-selection/catTimeline.ts b/task-launcher/src/tasks/same-different-selection/catTimeline.ts index bdd6bd6c6..e3fe40f3a 100644 --- a/task-launcher/src/tasks/same-different-selection/catTimeline.ts +++ b/task-launcher/src/tasks/same-different-selection/catTimeline.ts @@ -26,7 +26,6 @@ import { somethingSameDemo2, somethingSameDemo3, } from './trials/heavyInstructions'; -import { dataQualityScreen } from '../shared/trials/dataQuality'; import { initializeCat, jsPsych } from '../taskSetup'; export default function buildSameDifferentTimelineCat(config: Record, mediaAssets: MediaAssetsType) { @@ -34,13 +33,13 @@ export default function buildSameDifferentTimelineCat(config: Record, initializeCat(); - if (heavy) { - timeline.push(dataQualityScreen); - } timeline.push(taskFinished()); timeline.push(exitFullscreen); return { jsPsych, timeline }; diff --git a/task-launcher/src/tasks/shared/helpers/getStimulus.ts b/task-launcher/src/tasks/shared/helpers/getStimulus.ts index 403b95947..9274d39f2 100644 --- a/task-launcher/src/tasks/shared/helpers/getStimulus.ts +++ b/task-launcher/src/tasks/shared/helpers/getStimulus.ts @@ -14,6 +14,11 @@ export const getStimulus = (corpusType: string, blockNumber?: number) => { corpus = taskStore().corpora; + console.log(corpus) + if (blockNumber != undefined) { + console.log(corpus[corpusType][blockNumber]); + } + // if block number is specified, get next item from only the indicated block of the corpus blockNumber != undefined ? (itemSuggestion = cat.findNextItem(corpus[corpusType][blockNumber])) diff --git a/task-launcher/src/tasks/shared/helpers/prepareCat.ts b/task-launcher/src/tasks/shared/helpers/prepareCat.ts index 0c744cc1e..f72bf3630 100644 --- a/task-launcher/src/tasks/shared/helpers/prepareCat.ts +++ b/task-launcher/src/tasks/shared/helpers/prepareCat.ts @@ -4,31 +4,52 @@ import { cat } from '../../taskSetup'; import { jsPsych } from '../../taskSetup'; // separates trials from corpus into blocks depending on for heavy/light instructions and CAT -export function prepareCorpus(corpus: StimulusType[], randomStartBlock = true) { +export function prepareCorpus( + corpus: StimulusType[], + randomStartBlock = true, + downexCorpus?: StimulusType[], + fillInSdsDifficulty: boolean = false +) { const excludedTrialTypes = '3D'; // limit random starting items so that their difficulty is less than 0 const maxTrialDifficulty = 0; const cat: boolean = taskStore().runCat; let corpora; - const instructionPracticeTrials: StimulusType[] = corpus.filter( - (trial) => trial.trialType === 'instructions' || trial.assessmentStage === 'practice_response', - ); + let heavyInstructionPracticeTrials: StimulusType[] = []; + let downexTestTrials: StimulusType[] = []; - const heavyInstructionPracticeTrials: StimulusType[] = instructionPracticeTrials.filter( - (trial) => Number(trial.difficulty) < 0, - ); + if (downexCorpus) { + heavyInstructionPracticeTrials = downexCorpus.filter( + (trial) => trial.trialType === 'instructions' || trial.assessmentStage === 'practice_response', + ); + downexTestTrials = downexCorpus.filter((trial) => !heavyInstructionPracticeTrials.includes(trial)); + } - const lightInstructionPracticeTrials: StimulusType[] = instructionPracticeTrials.filter( - (trial) => Number(trial.difficulty) > 0 || trial.difficulty == null || isNaN(Number(trial.difficulty)), + const lightInstructionPracticeTrials: StimulusType[] = corpus.filter( + (trial) => trial.trialType === 'instructions' || trial.assessmentStage === 'practice_response', ); + const testTrials: StimulusType[] = corpus.filter((trial) => !lightInstructionPracticeTrials.includes(trial)); const corpusParts = { ipHeavy: heavyInstructionPracticeTrials, ipLight: lightInstructionPracticeTrials, - test: corpus.filter((trial) => !instructionPracticeTrials.includes(trial)), + test: testTrials, + downexTest: downexTestTrials, }; + // something same 1 trials inherit difficulty from the corresponding something same 2 trial + if (fillInSdsDifficulty) { + corpusParts.test.forEach((trial, index) => { + if (trial.trialType === 'something-same-1') { + const nextTrial = corpusParts.test[index + 1]; + if (nextTrial.trialType === 'something-same-2') { + trial.difficulty = nextTrial.difficulty; + } + } + }); + } + // separate out normed/unnormed trials const unnormedTrials: StimulusType[] = corpusParts.test.filter( (trial) => trial.difficulty == null || isNaN(Number(trial.difficulty)), @@ -49,25 +70,35 @@ export function prepareCorpus(corpus: StimulusType[], randomStartBlock = true) { randomStartBlock ? normedTrials.filter((trial) => !startItems.includes(trial)) : normedTrials; + const downexUnnormedTrials: StimulusType[] = downexTestTrials.filter( + (trial) => trial.difficulty == null || isNaN(Number(trial.difficulty)), + ); + + const downexCatCorpus: StimulusType[] = downexTestTrials.filter( + trial => !downexUnnormedTrials.includes(trial), + ); + corpora = { - ipHeavy: corpusParts.ipHeavy, // heavy instruction/practice trials - ipLight: corpusParts.ipLight, // light instruction/practice + ipHeavy: corpusParts.ipHeavy, // downex instruction/practice trials + ipLight: corpusParts.ipLight, // older kid instruction/practice start: startItems, // 5 random items to be used in starting block (all under a certain max difficulty) unnormed: unnormedTrials, // all items without IRT parameters + downexUnnormed: downexUnnormedTrials, cat: catCorpus, // all normed items for CAT + downexCat: downexCatCorpus, }; if (cat) { // if cat is running, put only normed trials into taskStore const newCorpora = { - practice: taskStore().corpora.practice, + downex: downexTestTrials, stimulus: catCorpus, }; taskStore('corpora', newCorpora); } else { // if cat is not running, put entire test portion of corpus into taskStore but leave out instruction/practice const newCorpora = { - practice: taskStore().corpora.practice, + practice: downexTestTrials, stimulus: corpusParts.test, }; taskStore('corpora', newCorpora); From 5763a05068ae373e2a4fcbb2d665ebe069019ea5 Mon Sep 17 00:00:00 2001 From: Zach Watson Date: Fri, 20 Feb 2026 14:10:28 -0800 Subject: [PATCH 2/3] remove log statements and fix math timeline --- task-launcher/src/tasks/math/timeline.ts | 1 + task-launcher/src/tasks/shared/helpers/getStimulus.ts | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/task-launcher/src/tasks/math/timeline.ts b/task-launcher/src/tasks/math/timeline.ts index d65b9c124..6db4cdca8 100644 --- a/task-launcher/src/tasks/math/timeline.ts +++ b/task-launcher/src/tasks/math/timeline.ts @@ -218,6 +218,7 @@ export default function buildMathTimeline(config: Record, mediaAsse let olderKidPractice: StimulusType[] = olderKidInstructionPractice.filter((trial: StimulusType) => trial.assessmentStage == 'practice_response'); let olderKidBlocks: StimulusType[][] = prepareMultiBlockCat(taskStore().corpora.stimulus); + taskStore('corpora', { stimulus: olderKidBlocks, downex: taskStore().corpora.downex }); taskStore('totalTestTrials', 0); // add to this while building out each block diff --git a/task-launcher/src/tasks/shared/helpers/getStimulus.ts b/task-launcher/src/tasks/shared/helpers/getStimulus.ts index 9274d39f2..403b95947 100644 --- a/task-launcher/src/tasks/shared/helpers/getStimulus.ts +++ b/task-launcher/src/tasks/shared/helpers/getStimulus.ts @@ -14,11 +14,6 @@ export const getStimulus = (corpusType: string, blockNumber?: number) => { corpus = taskStore().corpora; - console.log(corpus) - if (blockNumber != undefined) { - console.log(corpus[corpusType][blockNumber]); - } - // if block number is specified, get next item from only the indicated block of the corpus blockNumber != undefined ? (itemSuggestion = cat.findNextItem(corpus[corpusType][blockNumber])) From 0c3998f154ed5a56a62a5ec9f7fe3b85f9f55920 Mon Sep 17 00:00:00 2001 From: Zach Watson Date: Mon, 23 Feb 2026 12:15:03 -0800 Subject: [PATCH 3/3] allow cypress test to handle downex trials --- task-launcher/cypress/e2e/math.cy.js | 2 +- task-launcher/src/tasks/shared/trials/afcStimulus.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/task-launcher/cypress/e2e/math.cy.js b/task-launcher/cypress/e2e/math.cy.js index 11e38be6a..9045e3b3d 100644 --- a/task-launcher/cypress/e2e/math.cy.js +++ b/task-launcher/cypress/e2e/math.cy.js @@ -5,6 +5,6 @@ const math_url = 'http://localhost:8080/?task=egma-math'; describe('test math', () => { it('visits math and plays game', () => { cy.visit(math_url); - testAfc('alt', '.secondary'); + testAfc('alt', '.secondary, .image-medium, .primary'); }); }); diff --git a/task-launcher/src/tasks/shared/trials/afcStimulus.ts b/task-launcher/src/tasks/shared/trials/afcStimulus.ts index c550e6873..3e2578a35 100644 --- a/task-launcher/src/tasks/shared/trials/afcStimulus.ts +++ b/task-launcher/src/tasks/shared/trials/afcStimulus.ts @@ -277,7 +277,7 @@ function doOnLoad(layoutConfigMap: Record, trial?: Sti // flag correct answers with alt text for math if running a Cypress test if (window.Cypress && !isInstructionTrial) { - const choices: NodeListOf = document.querySelectorAll('.secondary'); + const choices: NodeListOf = document.querySelectorAll('.secondary, .image-medium, .primary'); choices[itemLayoutConfig.response.targetIndex].setAttribute('aria-label', 'correct'); } }