Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Respect dynamically added trial/timeline descriptions #3426

Merged
merged 15 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-plums-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"jspsych": minor
---

We added a feature that allows users to dynamically add or remove trials or nested timelines to a timeline array during runtime.
122 changes: 122 additions & 0 deletions docs/overview/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,128 @@ const after_if_trial = {
jsPsych.run([pre_if_trial, if_node, after_if_trial]);
```

## Modifying timelines at runtime

Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically add or remove trials and nested timelines during runtime.

### Adding timeline nodes at runtime
For example, you may have a branching point in your experiment where the participant is given 3 choices, each leading to a different timeline:

```javascript
const jspsych = initJsPsych();
let main_timeline = [];

const part1_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Part 1'
}

const choice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish.',
choices: ['1','2','3']
}
```
This would be trickier to implement with the `conditional_function` since it can only handle 2 branches -- case when `True` or case when `False`. Instead, you can modify the timeline by modifying `choice_trial` to dynamically adding a timeline at the end of the choice trial according to the chosen condition:

```javascript
const english_trial1 = {...};
const english_trial2 = {...};
const english_trial3 = {...};
// So on and so forth
const spanish_trial3 = {...};

const english_branch = [b1_t1, b1_t2, b1_t3];
const mandarin_branch = [b2_t1, b2_t2, b2_t3];
const spanish_branch = [b3_t1, b3_t2, b3_t3];

const choice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish.',
choices: ['1','2','3'],
on_finish: (data) => {
switch(data.response) {
case '1':
main_timeline.push(english_branch);
break;
case '2':
main_timeline.push(mandarin_branch);
break;
case '3':
main_timeline.push(spanish_branch);
break;
}
}
}
main_timeline.push(part1_trial, choice_trial);
```
During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `english_branch`, `mandarin_branch` and `spanish_branch` respectively, to the end of the `main_timeline`.

### Removing timeline nodes at runtime

You can also remove upcoming timeline nodes from a timeline at runtime. To demonstrate this, we can modify the above example by adding a 4th choice to `choice_trial` and another (nested) timeline to the tail of `main_timeline`:

```javascript
const choice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish. Press 4 to exit.',
jodeleeuw marked this conversation as resolved.
Show resolved Hide resolved
choices: ['1','2','3', '4'],
on_finish: (data) => {
switch(data.response) {
case '1':
main_timeline.push(english_branch);
break;
case '2':
main_timeline.push(mandarin_branch);
break;
case '3':
main_timeline.push(spanish_branch);
break;
case '4':
main_timeline.pop();
break;
}
}
}

const part2_timeline = [
{
type: JsPsychHtmlKeyboardResponse,
stimulus: 'Part 2'
}
// ...the rest of the part 2 trials
]

main_timeline.push(part1_trial, choice_trial, part2_timeline)
```
Now, if 1, 2 or 3 were chosen during runtime, `part2_timeline` will run after the dynamically added timeline corresponding to the choice (`english_branch` | `mandarin_branch` | `spanish_branch`) has been run; but if 4 was chosen, `part2_timeline` will be removed at runtime, and `main_timeline` will terminate.

### Exception cases for adding/removing timeline nodes dynamically
Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all the node(s) added (`english_branch` | `mandarin_branch` | `spanish_branch`) or removed (`part2_timeline`) occur at the end of the timeline via `push()` and `pop()`. If a node was added at a point in the timeline that has already been executed, it will not be executed:

```javascript
const choice_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish. Press 4 to exit.',
choices: ['1','2','3', '4'],
on_finish: (data) => {
switch(data.response) {
case '1':
main_timeline.splice(0,0,english_branch); // Adds english_branch to the start of main_timeline
break;
case '2':
main_timeline.push(mandarin_branch);
break;

...

main_timeline.push(part1_trial, choice_trial);
```
In the above implementation of `choice_trial`, choice 1 adds `english_branch` at the start of `main_timeline`, such that `main_timeline = [english_branch, part1_trial, choice_trial]`, but because the execution of `main_timeline` is past the first node at this point in runtime, the newly added `english_branch` will not be executed. Similarly, modifying `case '1'` in `choice_trial` to remove `part1_trial` will not change any behavior in the timeline.

!!! danger
In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped.
jodeleeuw marked this conversation as resolved.
Show resolved Hide resolved

## Timeline start and finish functions

You can run a custom function at the start and end of a timeline node using the `on_timeline_start` and `on_timeline_finish` callback function parameters. These are functions that will run when the timeline starts and ends, respectively.
Expand Down
127 changes: 122 additions & 5 deletions packages/jspsych/src/timeline/Timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,126 @@ describe("Timeline", () => {
expect((children[1] as Timeline).children.map((child) => child.index)).toEqual([1, 2]);
});

it("respects dynamically added child node descriptions", async () => {
TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [{ type: TestPlugin }];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
expect(timeline.children.length).toEqual(1);

timelineDescription.push({ timeline: [{ type: TestPlugin }] });
await TestPlugin.finishTrial();
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(2);
});

it("respects dynamically added child node descriptions at the start", async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this test makes sense. I think it will run the same exact trial twice, because of the runtime insertion stuff we looked at before.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I just tested it and you're right. should i just delete it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although the additional tests might have been helpful to find out how this behaves, I think they all boil down to testing runtime behavior of the list iteration – and are rather complicated for that. Technically, the "respects dynamically added child node descriptions" case should be all it needs to check that the Timeline class dynamically creates trials one by one – the rest is up to the runtime. I'd prefer to keep the unit tests as slim as possible here (without sacrificing coverage). Sorry for involving into this again 🙊

TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [{ type: TestPlugin }];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
expect(timeline.children.length).toEqual(1);

timelineDescription.splice(0, 0, { timeline: [{ type: TestPlugin }] });
await TestPlugin.finishTrial();
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(2);
});

it("dynamically added child node descriptions before a node after it has been run", async () => {
TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [{ type: TestPlugin }];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
expect(timeline.children.length).toEqual(1);

await TestPlugin.finishTrial();
timelineDescription.splice(0, 0, { timeline: [{ type: TestPlugin }] });
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(1);
});

it("respects dynamically removed end child node descriptions", async () => {
TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [
{ type: TestPlugin },
{ timeline: [{ type: TestPlugin }] },
{ type: TestPlugin },
];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
expect(timeline.children.length).toEqual(1); // Only the first child is instantiated because they are incrementally instantiated now

timelineDescription.pop();
await TestPlugin.finishTrial();
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(2);
expect(timeline.children).toEqual([expect.any(Trial), expect.any(Timeline)]);
});

it("respects dynamically removed middle child node descriptions", async () => {
TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [
{ type: TestPlugin },
{ timeline: [{ type: TestPlugin }] },
{ type: TestPlugin },
];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
expect(timeline.children.length).toEqual(1);

timelineDescription.splice(1, 1);
await TestPlugin.finishTrial();
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(2);
expect(timeline.children).toEqual([expect.any(Trial), expect.any(Trial)]);
});

it("dynamically remove first node after running it", async () => {
TestPlugin.setManualFinishTrialMode();

const timelineDescription: TimelineArray = [
{ type: TestPlugin, data: { I: 0 } },
{ timeline: [{ type: TestPlugin, data: { I: 1 } }] },
{ type: TestPlugin, data: { I: 2 } },
{ type: TestPlugin, data: { I: 3 } },
];
const timeline = createTimeline(timelineDescription);

const runPromise = timeline.run();
await TestPlugin.finishTrial();
timelineDescription.shift();
await TestPlugin.finishTrial();
await TestPlugin.finishTrial();
await runPromise;

expect(timeline.children.length).toEqual(3);
expect(timeline.children[0].getDataParameter().I).toEqual(0);
const secondChildDescription = timeline.children[1].description as TimelineDescription;
expect(secondChildDescription["timeline"][0]).toHaveProperty("data.I", 1);
expect(timeline.children[2].getDataParameter().I).toEqual(3);
});

describe("with `pause()` and `resume()` calls`", () => {
beforeEach(() => {
TestPlugin.setManualFinishTrialMode();
Expand All @@ -84,12 +204,9 @@ describe("Timeline", () => {

await TestPlugin.finishTrial();
expect(timeline.children[1].getStatus()).toBe(TimelineNodeStatus.COMPLETED);
expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING);

// Resolving the next trial promise shouldn't continue the experiment since no trial should be running.
await TestPlugin.finishTrial();

expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING);
// The timeline is paused, so it shouldn't have instantiated the next child node yet.
expect(timeline.children.length).toEqual(2);

timeline.resume();
await flushPromises();
Expand Down
21 changes: 12 additions & 9 deletions packages/jspsych/src/timeline/Timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ export class Timeline extends TimelineNode {
for (const timelineVariableIndex of timelineVariableOrder) {
this.setCurrentTimelineVariablesByIndex(timelineVariableIndex);

for (const childNode of this.instantiateChildNodes()) {
for (const childNodeDescription of this.description.timeline) {
const childNode = this.instantiateChildNode(childNodeDescription);

const previousChild = this.currentChild;
this.currentChild = childNode;
childNode.index = previousChild
Expand Down Expand Up @@ -151,14 +153,15 @@ export class Timeline extends TimelineNode {
}
}

private instantiateChildNodes() {
const newChildNodes = this.description.timeline.map((childDescription) => {
return isTimelineDescription(childDescription)
? new Timeline(this.dependencies, childDescription, this)
: new Trial(this.dependencies, childDescription, this);
});
this.children.push(...newChildNodes);
return newChildNodes;
private instantiateChildNode(
childDescription: TimelineDescription | TimelineArray | TrialDescription
) {
const newChildNode = isTimelineDescription(childDescription)
? new Timeline(this.dependencies, childDescription, this)
: new Trial(this.dependencies, childDescription, this);

this.children.push(newChildNode);
return newChildNode;
}

private currentTimelineVariables: Record<string, any>;
Expand Down
Loading