Synchronization

Warning

This functionality is still experimental. Some users have reported database deadlock issues when running live experiments, and we are currently trying to replicate and debug these issues.

In some experiments we need to be able to synchronize certain groups of participants to do the same things at the same time. For example, we might want to implement a behavioral economics game where participants have to make certain kinds of decisions and receive payouts depending on what the other participants in their group did. PsyNet provides advanced synchronization utilities for supporting such experiments.

There are two main timeline constructs that are used to implement such synchronization. The Grouper is responsible for creating groups of participants, whereas the GroupBarrier is responsible for synchronizing participants within groups.

Grouper

One straightforward type of Grouper is the SimpleGrouper. It may be included in the timeline as follows:

SimpleGrouper(
    group_type="rock_paper_scissors",
    group_size=2,
),

This SimpleGrouper organizes participants into groups of 2. By default it will create a new group of 2 each time 2 participants are ready and waiting, but an optional batch_size parameter can be used to delay group formation until more participants are waiting.

The groups created by Groupers are represented by SyncGroup objects. If a participant is a member of just one active SyncGroup, then it can be accessed with code as follows:

participant.sync_group

If the participant is a member of multiple active SyncGroups, then they can be accessed via participant.active_sync_groups, which takes the form of a dictionary keyed by group_type. The full list of participants within the SyncGroup can then be accessed (and modified) via sync_group.participants, which is a list.

It is possible to put multiple Grouper constructs in a timeline. If they have different group_type parameters then they will be used to create different grouping namespaces. Groupers with the same group_type can be used to regroup the participants into different groupings as they progress through the experiment. However, it is not possible to be in multiple groups with the same group_type simultaneously; one must place a GroupCloser in the timeline to close the group before assigning the participants to a new one.

Group Barrier

A Group Barrier may be included in the timeline as follows:

GroupBarrier(
    id_="finished_trial",
    group_type="rock_paper_scissors",
    on_release=self.score_trial,
)

When participants reach this barrier, they will be told to wait until all participants in their group are also waiting at that barrier. An optional on_release function can be provided to the barrier, which will be executed on the group of participants at the point when they leave the barrier.

Synchronization in trial makers

It is perfectly possible to use these synchronization constructs within trial makers. In this case, it is usually wise to provide a sync_group_type argument to the trial maker, for example:

RockPaperScissorsTrialMaker(
    id_="rock_paper_scissors",
    trial_class=RockPaperScissorsTrial,
    nodes=[
        StaticNode(definition={"color": color})
        for color in ["red", "green", "blue"]
    ],
    expected_trials_per_participant=3,
    max_trials_per_participant=3,
    sync_group_type="rock_paper_scissors",
)

This tells the trial maker to synchronize the logic of assigning participants to nodes according to their SyncGroup. By default, each group has a randomly assigned leader; node allocation is determined by standard PsyNet logic for that leader, as if that person were taking that trial maker by themselves; the other participants in that group then ‘follow’ that leader, being assigned to the same nodes as the leader on each trial.

Using a sync_group_type parameter means that the beginning of each trial is synchronized across all participants within a given group. It is possible to synchronize other parts of the trial by including further GroupBarriers within the trial, for example:

def show_trial(self, experiment, participant):
    return join(
        GroupBarrier(
            id_="wait_for_trial",
            group_type="rock_paper_scissors",
        ),
        self.choose_action(color=self.definition["color"]),
        GroupBarrier(
            id_="finished_trial",
            group_type="rock_paper_scissors",
            on_release=self.score_trial,
        ),
    )

Demo

The ‘rock, paper, scissors’ demo provides an example of the full-scale use of these features. The source code is provided below:

from typing import List

from dominate import tags

import psynet.experiment
from psynet.bot import Bot, advance_past_wait_pages
from psynet.consent import NoConsent
from psynet.modular_page import ModularPage, PushButtonControl
from psynet.page import InfoPage, SuccessfulEndPage
from psynet.participant import Participant
from psynet.sync import GroupBarrier, SimpleGrouper
from psynet.timeline import Timeline, join
from psynet.trial.static import StaticNode, StaticTrial, StaticTrialMaker
from psynet.utils import get_logger

logger = get_logger()


class RockPaperScissorsTrialMaker(StaticTrialMaker):
    pass


class RockPaperScissorsTrial(StaticTrial):
    time_estimate = 5
    accumulate_answers = True

    def show_trial(self, experiment, participant):
        return join(
            GroupBarrier(
                id_="wait_for_trial",
                group_type="rock_paper_scissors",
            ),
            self.choose_action(color=self.definition["color"]),
            GroupBarrier(
                id_="finished_trial",
                group_type="rock_paper_scissors",
                on_release=self.score_trial,
            ),
        )

    def choose_action(self, color):
        prompt = tags.p("Choose your action:", style=f"color: {color};")
        return ModularPage(
            "choose_action",
            prompt,
            PushButtonControl(
                choices=["rock", "paper", "scissors"],
            ),
            time_estimate=5,
            save_answer="last_action",
            # extras=ChatRoom(),
        )

    def show_feedback(self, experiment, participant):
        score = participant.var.last_trial["score"]
        if score == -1:
            outcome = "You lost."
        elif score == 0:
            outcome = "You drew."
        else:
            assert score == 1
            outcome = "You won!"

        prompt = (
            f"You chose {participant.var.last_trial['action_self']}, "
            + f"your partner chose {participant.var.last_trial['action_other']}. "
            + outcome
        )

        return InfoPage(
            prompt,
            time_estimate=5,
        )

    def score_trial(self, participants: List[Participant]):
        assert len(participants) == 2
        answers = [participant.var.last_action for participant in participants]
        score_0 = self.scoring_matrix[answers[0]][answers[1]]
        score_1 = -score_0
        participants[0].var.last_trial = {
            "action_self": answers[0],
            "action_other": answers[1],
            "score": score_0,
        }
        participants[1].var.last_trial = {
            "action_self": answers[1],
            "action_other": answers[0],
            "score": score_1,
        }

    scoring_matrix = {
        "rock": {
            "rock": 0,
            "paper": -1,
            "scissors": 1,
        },
        "paper": {
            "rock": 1,
            "paper": 0,
            "scissors": -1,
        },
        "scissors": {"rock": -1, "paper": 1, "scissors": 0},
    }


class Exp(psynet.experiment.Experiment):
    label = "Rock paper scissors demo"

    initial_recruitment_size = 1

    timeline = Timeline(
        NoConsent(),
        SimpleGrouper(
            group_type="rock_paper_scissors",
            group_size=2,
        ),
        RockPaperScissorsTrialMaker(
            id_="rock_paper_scissors",
            trial_class=RockPaperScissorsTrial,
            nodes=[
                StaticNode(definition={"color": color})
                for color in ["red", "green", "blue"]
            ],
            expected_trials_per_participant=3,
            max_trials_per_participant=3,
            sync_group_type="rock_paper_scissors",
        ),
        SuccessfulEndPage(),
    )

    test_n_bots = 2
    test_mode = "serial"

    def test_serial_run_bots(self, bots: List[Bot]):
        from psynet.page import WaitPage

        advance_past_wait_pages(bots)

        page = bots[0].get_current_page()
        assert page.label == "choose_action"
        bots[0].take_page(page, response="rock")
        page = bots[0].get_current_page()
        assert isinstance(page, WaitPage)

        page = bots[1].get_current_page()
        assert page.label == "choose_action"
        bots[1].take_page(page, response="paper")

        advance_past_wait_pages(bots)

        pages = [bot.get_current_page() for bot in bots]
        assert pages[0].content == "You chose rock, your partner chose paper. You lost."
        assert pages[1].content == "You chose paper, your partner chose rock. You won!"

        bots[0].take_page()
        bots[1].take_page()
        advance_past_wait_pages(bots)

        bots[0].take_page(page, response="scissors")
        bots[1].take_page(page, response="paper")
        advance_past_wait_pages(bots)

        pages = [bot.get_current_page() for bot in bots]

        logger.info("Bot 1: %s", pages[0].content)
        logger.info("Bot 2: %s", pages[1].content)

        assert (
            pages[0].content == "You chose scissors, your partner chose paper. You won!"
        )
        assert (
            pages[1].content
            == "You chose paper, your partner chose scissors. You lost."
        )

        bots[0].take_page()
        bots[1].take_page()
        advance_past_wait_pages(bots)

        bots[0].take_page(page, response="scissors")
        bots[1].take_page(page, response="scissors")
        advance_past_wait_pages(bots)

        pages = [bot.get_current_page() for bot in bots]
        assert (
            pages[0].content
            == "You chose scissors, your partner chose scissors. You drew."
        ), (
            "A rare error sometimes occurs here. If you see it, please report it to Peter Harrison (pmcharrison) for "
            "further debugging."
        )
        assert (
            pages[1].content
            == "You chose scissors, your partner chose scissors. You drew."
        ), (
            "A rare error sometimes occurs here. If you see it, please report it to Peter Harrison (pmcharrison) for "
            "further debugging."
        )

        bots[0].take_page()
        bots[1].take_page()
        advance_past_wait_pages(bots)

        pages = [bot.get_current_page() for bot in bots]
        for page in pages:
            assert isinstance(page, SuccessfulEndPage)