Show:

File: app/randomizers/random-parameter-set.js

/**
* @module exp-player
* @submodule randomizers
*/

import Ember from 'ember';
import Substituter from '../utils/replace-values';

/**
* Randomizer to implement flexible condition assignment and counterbalancing by
* allowing the user to specify an arbitrary sequence of frames to create. A
* set of parameters is randomly selected from a list of available parameterSets,
* and these parameters are substituted in to the parameters specified in the
* list of frames.
*
* For a more detailed walkthrough, please see
* https://lookit.readthedocs.io/en/develop/experiments.html#randomizer-frames
*
* To use, define a frame with 'kind': 'choice' and 'sampler': 'random-parameter-set',
* as shown below, in addition to the parameters described under 'properties'.
*
* This
*
```json
'frames': {
    'test-trials': {
        'sampler': 'random-parameter-set',
        'kind': 'choice',
        'commonFrameProperties': {
            'kind': 'exp-lookit-story-page',
            'baseDir': 'https://s3.amazonaws.com/lookitcontents/ingroupobligations/',
            'audioTypes': ['mp3', 'ogg'],
            'doRecording': true,
            'autoProceed': false,
            'parentTextBlock': {
                'title': 'Parents!',
                'text': 'Common instructions across test trials here',
                'emph': true
            }
        },
        'frameList': [
            {
                'images': [
                    {
                        'id': 'agent',
                        'src': 'AGENTIMG1',
                        'left': '40',
                        'width': '20',
                        'top': '10'
                    },
                    {
                        'id': 'left',
                        'src': 'LEFTIMG1',
                        'left': '10',
                        'width': '20',
                        'top': '50'
                    },
                    {
                        'id': 'right',
                        'src': 'RIGHTIMG1',
                        'left': '70',
                        'width': '20',
                        'top': '50'
                    }
                ],
                'audioSources': [
                    {
                        'audioId': 'questionaudio',
                        'sources': [{'stub': 'QUESTION1AUDIO'}],
                        'highlights': 'QUESTION1HIGHLIGHTS'
                    }
                ]
            },
            {
                'images': [
                    {
                        'id': 'agent',
                        'src': 'AGENTIMG2',
                        'left': '40',
                        'width': '20',
                        'top': '10'
                    },
                    {
                        'id': 'left',
                        'src': 'LEFTIMG2',
                        'left': '10',
                        'width': '20',
                        'top': '50'
                    },
                    {
                        'id': 'right',
                        'src': 'RIGHTIMG2',
                        'left': '70',
                        'width': '20',
                        'top': '50'
                    }
                ],
                'audioSources': [
                    {
                        'audioId': 'questionaudio',
                        'sources': [{'stub': 'QUESTION2AUDIO'}],
                        'highlights': 'QUESTION2HIGHLIGHTS'
                    }
                ]
            }
        ],
        'parameterSets': [
            {
                'AGENTIMG1': 'flurpagent1.jpg',
                'LEFTIMG1': 'flurpvictim1.jpg',
                'RIGHTIMG1': 'zazzvictim1.jpg',
                'QUESTION1AUDIO': 'flurpleftmean1',
                'QUESTION1HIGHLIGHTS': [
                    {'range': [0.399293,	3.617124], 'image': 'agent'},
                    {'range': [5.085112,	6.811467], 'image': 'left'},
                    {'range': [6.905418,	8.702236], 'image': 'right'}
                ],
                'AGENTIMG2': 'flurpagent2.jpg',
                'LEFTIMG2': 'flurpvictim2.jpg',
                'RIGHTIMG2': 'zazzvictim2.jpg',
                'QUESTION2AUDIO': 'flurpleftinduct1',
                'QUESTION2HIGHLIGHTS': [
                    {'range': [0.372569,	5.309110], 'image': 'agent'},
                    {'range': [5.495395,	7.209213], 'image': 'left'},
                    {'range': [5.495395,	7.209213], 'image': 'right'},
                    {'range': [9.966225,	11.922212], 'image': 'left'},
                    {'range': [12.052612,	14.008600], 'image': 'right'}
                ]
            },
            {
                'AGENTIMG1': 'zazzagent1.jpg',
                'LEFTIMG1': 'flurpvictim1.jpg',
                'RIGHTIMG1': 'zazzvictim1.jpg',
                'QUESTION1AUDIO': 'zazzrightnice1',
                'QUESTION1HIGHLIGHTS': [
                    {'range': [0.348454,	3.736871], 'image': 'agent'},
                    {'range': [5.395033,	6.884975], 'image': 'left'},
                    {'range': [6.969085,	8.975701], 'image': 'right'}
                ],
                'AGENTIMG2': 'zazzagent2.jpg',
                'LEFTIMG2': 'flurpvictim2.jpg',
                'RIGHTIMG2': 'zazzvictim2.jpg',
                'QUESTION2AUDIO': 'zazzrightinduct1',
                'QUESTION2HIGHLIGHTS': [
                    {'range': [0.572920,	5.138376], 'image': 'agent'},
                    {'range': [5.335317,	7.089884], 'image': 'left'},
                    {'range': [5.335317,	7.089884], 'image': 'right'},
                    {'range': [9.721735,	11.565821], 'image': 'left'},
                    {'range': [11.655340,	13.535233], 'image': 'right'}
                ]
            }
        ],
        'parameterSetWeights': [1, 1]
    }
}

* ```
* @class Random-parameter-set
*/

function getRandomElement(arr, weights) {
    weights = weights || Array(arr.length).fill(1);
    var totalProb = weights.reduce((a, b) => a + b, 0);
    var randPos = Math.random() * totalProb;

    var weightSum = 0;
    for (var i = 0; i < arr.length; i++) {
        weightSum += weights[i];
        if (randPos <= weightSum) {
            return [i, arr[i]];
        }
    }
}

// TODO: in the future we may want to allow nesting of the list-object-selector syntax,
// e.g. LISTVAR__3__4, LISTVAR1__LISTVAR2__3, LISTVAR1__1__LISTVAR2__3 - but this quickly
// also requires appropriate processing of parentheses/order-of-operations.

var randomizer = function(frameId, frameConfig, pastSessions, resolveFrame, child) {

    // Data provided to randomizer (properties of frameConfig):

    /**
     * Object describing common parameters to use in EVERY frame created
     * by this randomizer. Parameter names and values are as described in
     * the documentation for the frameType used.
     *
     * @property {Object} commonFrameProperties
     */

    /**
     * List of frames to be created by this randomizer. Each frame is an
     * object with any necessary frame-specific properties specified. The
     * `kind` of frame can be specified either here (per frame) or in
     * `commonFrameProperties`. If a property is defined for a given frame both
     * in this frame list and in `commonFrameProperties`, the value in the frame
     * list will take precedence.
     *
     * (E.g., you could include `'kind': 'normal-frame'` in
     * `commmonFrameProperties`, but for a single frame in `frameList`, include
     * `'kind': 'special-frame'`.)
     *
     * Any property VALUES within any of the frames in this list which match
     * a property NAME in the selected `parameterSet` will be replaced by the
     * corresponding `parameterSet` value. E.g., suppose a frame in `frameList` is
     *
```
{
    'leftImage': 'LEFTIMAGE1',
    'rightImage': 'frog.jpg',
    'size': 'IMAGESIZE'
}
```
     *
     * and the row that has been selected randomly of `parameterSets` is
     *
```
{
    'LEFTIMAGE1': 'toad.jpg',
    'LEFTIMAGE2': 'dog.jpg',
    'IMAGESIZE': 250
}
```
     *
     * Then the frame would be transformed into:
```
{
    'leftImage': 'toad.jpg',
     'rightImage': 'frog.jpg',
     'size': 250
}
```
     *
     * The same values may be applied across multiple frames. For instance,
     * suppose `frameList` is

```
       [
            {
                'leftImage': 'LEFTIMAGE1',
                'rightImage': 'frog.jpg',
                'size': 'IMAGESIZE'
            },
            {
                'leftImage': 'LEFTIMAGE2',
                'rightImage': 'frog.jpg',
                'size': 'IMAGESIZE'
            }
        ]
```

     * Then the corresponding processed frames would include the values
```
       [
            {
                'leftImage': 'toad.jpg',
                'rightImage': 'frog.jpg',
                'size': 250
            },
            {
                'leftImage': 'dog.jpg',
                'rightImage': 'frog.jpg',
                'size': 250
            }
        ]
```
     * A property value like `IMAGESIZE` may be placed in a frame definition
     * nested within another object (at any depth) or within a list and
     * will still be replaced.
     *
     * You can also use selectors to randomly sample from or permute
     * a list given in a `parameterSet`. Suppose `LISTVAR` is defined in
     * a `parameterSet` as `THELIST`, e.g. a list of potential stimuli. Within frames in your `frameList`
     * (and in `commonFrameProperties`), you can use any of the following:
     *
     * * Select the Nth element (0-indexed) of `THELIST`: (Will cause error if `N >= THELIST.length`)
```
    'parameterName': 'LISTVAR#N'
```
     * * Select (uniformly) a random element of `THELIST`:
```
    'parameterName': 'LISTVAR#RAND'
```
    * * Set `parameterName` to a random permutation of `THELIST`:
```
    'parameterName': 'LISTVAR#PERM'
```
    * * Select the next element in a random permutation of `THELIST`, which is used across all
    * substitutions in this randomizer. This allows you, for instance, to provide a list
    * of possible images in your `parameterSet`, and use a different one each frame with the
    * subset/order randomized per participant. If more `LISTVAR_UNIQ` parameters than
    * elements of `THELIST` are used, we loop back around to the start of the permutation
    * generated for this randomizer.
```
    'parameterName': 'LISTVAR#UNIQ'
```
     *
     * @property {Object[]} frameList
     */

    /**
     * Array of parameter sets to randomly select from in order to determine
     * the parameters for each frame in this session.
     *
     * A single element of parameterSets will be applied to a given session.
     *
     * @property {Object[]} parameterSets
     */

    /**
     * [Optional] How to select a parameterSet for a participant who has previously
     * participated in this study. Must be one of 'random' (default), 'persist', or
     * 'rotate'. Meanings:
     *
     * * `"random"`: regardless of any previous sessions from this participant, select a
     *    parameterSet for this participant as usual (including using parameterSetWeights if
     *    provided). Default behavior.
     *
     * * `"persist"`: Continue assigning the same participant to the same parameterSet for all
     *    sessions.
     *
     * * `"rotate"`: The first time, assign parameterSet randomly (per parameterSetWeights if
     *    given); after that, each time the participant participates assign them to the next
     *    parameterSet in the list. Subtracts length of parameterSets until the 'next' index
     *    is in range.
     *
     * The most recent session in which the `conditions` data includes an element that looks
     * like it was generated by this same randomizer (i.e., with key ending in `-frameId`,
     * like `-test-trials`) will always be used for assignment. **Only sessions with a
     * completed consent frame are considered, so that participants are not rotated through
     * conditions simply due to refreshing the setup page.**
     *
     * Note: The "same" or "next" parameterSets are determined by the **index** of the
     *    previously-selected parameterSet. That is, if you were assigned to conditionNum 0
     *    (index 0 in parameterSets) last time, you will be assigned to conditionNum 0 again
     *    this time if `conditionForAdditionalSessions` is `"persist"` and conditionNum 1 if
     *    `conditionForAdditionalSessions` is `"rotate"`. So if you update the list of parameterSets
     *    in your study - e.g. to fix a bug or clarify wording - the new values will be used
     *    even for repeat participants. But be careful that you do not reorder them unless you
     *    intend to, say, swap all participants to the opposite condition on a specified date!
     *
     *    If the previous index is now outside the range of the parameterSets list (e.g., you
     *    used to have 6 conditions, and the participant was previously in conditionNum 5,
     *    but then you changed parameterSets to have only 3 elements) and conditionForAdditionalSessions is
     *    `"persist"`, then the participant is assigned to the last element of parameterSets.
     *
     * @property {String} conditionForAdditionalSessions
     */

    /**
     * [Optional] Array of weights for parameter sets; elements correspond to
     * elements of parameterSets. The probability of selecting an element
     * `parameterSets[i]` is `parameterSetWeights[i]/sum(parameterSetWeights)`.
     *
     * If not provided, all `parameterSets` are weighted equally.
     *
     * This is intended to allow manual control of counterbalancing during
     * data collection, e.g. to allow one condition to 'catch up' if it was
     * randomly selected less often.
     *
     * Instead of providing a single list of the same length as parameterSets,
     * you may instead provide a list of objects specifying the weights to use within
     * various age ranges, like this:
     *
```
    'parameterSetWeights': [
        {
            'minAge': 0,
            'maxAge': 365,
            'weights': [1, 0, 1]
        },
        {
            'minAge': 365,
            'maxAge': 10000,
            'weights': [0, 1, 0]
        },
    ]
```
     * The child's age in days will be computed, and the weights used will be based on the
     * first element of `parameterSetWeights` where the child falls between the min and max
     * age. In the example above, children under one year old will be assigned to either
     * the first or third condition; children over a year will be assigned to the second condition.
     * This may be useful for researchers who need to balance condition assignment per
     * age bracket. As you code data and realize you are set on 3-year-olds in condition A, for
     * instance, you can stop assigning any more 3-year-olds to that condition.
     *
     * @property {Number[]} parameterSetWeights
     */

    // Select a parameter set to use for this trial.
    var parameterSet;
    var parameterSetIndex;

    // Assign based on previous sessions, if appropriate.
    var selectedBasedOnPreviousSession = false;
    if (frameConfig.hasOwnProperty('conditionForAdditionalSessions') && frameConfig.conditionForAdditionalSessions != 'random') {
        // if there are any previous sessions with the appropriate info
        var mostRecentSession = pastSessions.find(sess => sess.get('completedConsentFrame') && Object.keys(sess.get('conditions', {})).some(frId => frId.endsWith('-' + frameId)));
        if (mostRecentSession) {
            let conditions = mostRecentSession.get('conditions');
            let conditionKey = Object.keys(conditions).find(frId => frId.endsWith('-' + frameId));
            let prevCondition = conditions[conditionKey];
            if (frameConfig.conditionForAdditionalSessions == 'persist') {
                parameterSetIndex = prevCondition.conditionNum;
                if (parameterSetIndex >= frameConfig.parameterSets.length) {
                    parameterSetIndex = frameConfig.parameterSets.length - 1;
                }
            } else if (frameConfig.conditionForAdditionalSessions == 'rotate') {
                parameterSetIndex = prevCondition.conditionNum + 1;
                while (parameterSetIndex >= frameConfig.parameterSets.length) {
                    parameterSetIndex = parameterSetIndex - frameConfig.parameterSets.length;
                }
            } else {
                throw `Unrecognized conditionForAdditionalSessions option. Should be one of 'persist', 'random', or 'rotate'.`;
            }
            parameterSet =  frameConfig.parameterSets[parameterSetIndex];
            selectedBasedOnPreviousSession = true;
        }
    }

    // Otherwise... (not assigning based on previous sessions)
    if (!selectedBasedOnPreviousSession) {

        var equalWeights = new Array(frameConfig.parameterSets.length).fill(1);
        if (!(frameConfig.hasOwnProperty('parameterSetWeights'))) {
            frameConfig.parameterSetWeights = equalWeights;
        } else {
            if (typeof frameConfig.parameterSetWeights[0] === 'object') {
                // Get child's age in days
                var childDOB;
                try {
                    childDOB = child.get('birthday');
                    if (isNaN(childDOB)) {
                        console.warn('No child birthday available for randomization. Using today\'s date.');
                        childDOB = new Date().getTime();
                    }
                } catch (error) {
                    console.warn('No child birthday available for randomization. Using today\'s date.');
                    childDOB = new Date().getTime();
                }
                var childAgeDays = (new Date().getTime() - childDOB) / (1000 * 60 * 60 * 24);

                // Find the age range this child fits in
                var ageBasedWeightObj = frameConfig.parameterSetWeights.find(function(element) {
                    return (element.minAge <= childAgeDays && childAgeDays <= element.maxAge);
                });
                if (ageBasedWeightObj) {
                    frameConfig.parameterSetWeights = ageBasedWeightObj.weights;
                    console.log('Using age-based randomization parameters');
                } else { // Set to equal weights if child doesn't fall in any range given
                    console.warn('Child does not fall into any designated age range for randomization. Weighting parameter sets equally.');
                    frameConfig.parameterSetWeights = equalWeights;
                }
            }
        }

        var parameterData = getRandomElement(frameConfig.parameterSets, frameConfig.parameterSetWeights);
        parameterSetIndex = parameterData[0];
        parameterSet = parameterData[1];
    }

    var frames = [];
    var thisFrame = {};

    var substituter = new Substituter();

    for (var iFrame = 0; iFrame < frameConfig.frameList.length; iFrame++) {

        // Assign parameters common to all frames made by this randomizer.
        // Use deep copies to make sure that substitutions (replaceValues)
        // don't affect the original frameConfig values if they're objects
        // themselves!!
        thisFrame = {};
        Ember.$.extend(true, thisFrame, frameConfig.commonFrameProperties);

        // Assign parameters specific to this frame (allow to override
        // common parameters assigned above)
        Ember.$.extend(true, thisFrame, frameConfig.frameList[iFrame]);

        // Substitute any properties that can be replaced based on
        // the parameter set.
        thisFrame = substituter.replaceValues(thisFrame, parameterSet);

        // Assign frame ID
        //thisFrame.id = `${frameId}`;

        thisFrame = resolveFrame(frameId, thisFrame)[0];
        frames.push(...thisFrame); // spread syntax important here -- a list of frames is returned by resolveFrame.
    }

    /**
     * Parameters captured and sent to the server
     *
     * @attribute conditions
     * @param {Number} conditionNum the index of the parameterSet chosen
     * @param {Object} parameterSet the parameterSet chosen
     */

    return [frames, {'conditionNum': parameterSetIndex, 'parameterSet': parameterSet}];

};

export default randomizer;

// Export helper functions to support unit testing
export { getRandomElement, randomizer };