def select_participant(wheel, participant): """ Register the selection of a participant by updating the weights of all participants for a given wheel :param wheel: Wheel dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "participant_count": number of participants in the wheel, } :param participant: Participant dictionary: { "id": string ID of the participant (DDB Hash Key), "name": string name of the participant, "url": Participant's URL, "wheel_id": string ID of the wheel the participant belongs to, "weight": participant's weight in the selection algorithm } :return: None """ participant_count = wheel['participant_count'] # All other participants get a slice of that participant's weight while the participant is weighted to 0 if participant_count > 1: weight_share = participant['weight'] / (participant_count - 1) with WheelParticipant.batch_writer() as batch: for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): if p['id'] == participant['id']: p['weight'] = 0 else: p['weight'] += Decimal(weight_share) batch.put_item(Item=p)
def delete_wheel(event): """ Deletes the wheel and all of its participants :param event: Lambda event containing the API Gateway request path parameter wheel_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) } } :return: response dictionary """ wheel_id = event['pathParameters']['wheel_id'] # DynamoDB always succeeds for delete_item, Wheel.delete_item(Key={'id': wheel_id}) # Clear out all participants of the wheel. Query will be empty if it was already deleted with WheelParticipant.batch_writer() as batch: query_params = { 'KeyConditionExpression': Key('wheel_id').eq(wheel_id), 'ProjectionExpression': 'id' } # We don't use the default generator here because we don't want the deletes to change the query results for p in list(WheelParticipant.iter_query(**query_params)): batch.delete_item(Key={'id': p['id'], 'wheel_id': wheel_id})
def suggest_participant(wheel): """ Suggest a participant given weights of all participants with randomization. This is weighted selection where all participants start with a weight of 1, so the sum of the weights will always equal the number of participants :param wheel: Wheel dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "participant_count": number of participants in the wheel, } :return: ID of the suggested participant """ if wheel['participant_count'] == 0: raise BadRequestError("Cannot suggest a participant when the wheel doesn't have any!") query_params = {'KeyConditionExpression': Key('wheel_id').eq(wheel['id'])} participants = WheelParticipant.iter_query(**query_params) selected_total_weight = random.random() * float(sum([participant['weight'] for participant in participants])) # We do potentially want to return the last participant just as a safeguard for rounding errors participant = None for participant in WheelParticipant.iter_query(**query_params): selected_total_weight -= float(participant['weight']) if selected_total_weight <= 0: return participant['id'] return participant['id']
def update_participant(event): """ Update a participant's name and/or url :param event: Lambda event containing the API Gateway request body including updated name or url and the path parameters wheel_id and participant_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) "participant_id": string ID of the participant (DDB Hash Key) }, "body": { "id": string ID of the participant (DDB Hash Key), "name": string name of the wheel (optional), "url: Valid URL for the participant (optional), } } :return: response dictionary containing the updated participant object if successful { "body": { "id": string ID of the participant (DDB Hash Key), "wheel_id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "url: URL for the participant, "created_at": creation timestamp, "updated_at": updated timestamp, } } """ wheel_id = event['pathParameters']['wheel_id'] participant_id = event['pathParameters']['participant_id'] # Check that the participant exists participant = WheelParticipant.get_existing_item(Key={ 'id': participant_id, 'wheel_id': wheel_id }) body = event['body'] params = {'updated_at': get_utc_timestamp()} if not check_string(body.get('name', 'Not Specified')) or not check_string( body.get('url', 'Not Specified')): raise base.BadRequestError( "Participants names and urls must be at least 1 character in length" ) if 'name' in body: params['name'] = body['name'] if 'url' in body: params['url'] = body['url'] WheelParticipant.update_item(Key={ 'id': participant_id, 'wheel_id': wheel_id }, **to_update_kwargs(params)) participant.update(params) return participant
def test_selection_cycle(mock_dynamodb, setup_data, mock_participant_table): def get_participant_with_id(participants, target_id): for p in participants: if p['id'] == target_id: return p return None rngstate = random.getstate() random.seed(0) # Make the (otherwise pseudorandom) test repeatable. participants = WheelParticipant.scan({})['Items'] wheel = setup_data['wheel'] total_weight_of_chosens = 0 num_iterations = 200 distro = {} for participant in participants: distro[participant['name']] = 0 for _ in range(0, num_iterations): chosen_id = choice_algorithm.suggest_participant(wheel) chosen_was = get_participant_with_id(participants, chosen_id) chosen_was_weight = chosen_was['weight'] distro[chosen_was['name']] = distro[chosen_was['name']] + 1 choice_algorithm.select_participant(wheel, chosen_was) participants = WheelParticipant.scan({})['Items'] chosen_now = get_participant_with_id(participants, chosen_id) chosen_now_weight = chosen_now['weight'] assert chosen_was_weight > 0.0 assert chosen_now_weight == 0 total_weight_of_chosens += chosen_was_weight total_weight = sum( [participant['weight'] for participant in participants]) assert abs(total_weight - len(participants)) < epsilon # Must match human-inspected reasonable values for the RNG seed defined # above for number of times each participant was chosen, and the total # weight of participants selected. These are a rough equivalent to # ensuring that the sequence of chosen participants matches the observed # test run. dv = list(distro.values()) list.sort(dv) human_observed_selection_counts = [26, 27, 27, 29, 29, 31, 31] human_observed_total_weight = 317.8786415239279 assert dv == human_observed_selection_counts assert abs(float(total_weight_of_chosens) - human_observed_total_weight) < epsilon # Put things back the way they were. random.setstate(rngstate)
def wrap_participant_creation(wheel, participant): participant['weight'] = 1 yield count = 0 with WheelParticipant.batch_writer() as batch: for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): count += 1 Wheel.update_item(Key={'id': wheel['id']}, **to_update_kwargs({'participant_count': count}))
def create_participant(event): """ Create a participant :param event: Lambda event containing the API Gateway request body including a name and a url and the path parameter wheel_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) }, "body": { "name": participant name string, "url: Valid URL for the participant, } } :return: response dictionary containing new participant object if successful { "body": { "id": string ID of the participant (DDB Hash Key), "wheel_id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "url: URL for the participant, "created_at": creation timestamp, "updated_at": updated timestamp, } } """ wheel_id = event['pathParameters']['wheel_id'] body = event['body'] if not check_string(body.get('name', None)) or not check_string( body.get('url', None)): raise base.BadRequestError( "Participants require a name and url which must be at least 1 character in length" ) wheel = Wheel.get_existing_item(Key={'id': wheel_id}) create_timestamp = get_utc_timestamp() participant = { 'wheel_id': wheel_id, 'id': get_uuid(), 'name': body['name'], 'url': body['url'], 'created_at': create_timestamp, 'updated_at': create_timestamp, } with choice_algorithm.wrap_participant_creation(wheel, participant): WheelParticipant.put_item(Item=participant) return participant
def delete_participant(event): """ Deletes the participant from the wheel and redistributes wheel weights :param event: Lambda event containing the API Gateway request path parameters wheel_id and participant_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) "participant_id": string ID of the participant (DDB Hash Key) }, } :return: response dictionary """ wheel_id = event['pathParameters']['wheel_id'] participant_id = event['pathParameters']['participant_id'] # Make sure the wheel exists wheel = Wheel.get_existing_item(Key={'id': wheel_id}) # REST-ful Deletes are idempotent and should not error if it's already been deleted response = WheelParticipant.delete_item(Key={ 'wheel_id': wheel_id, 'id': participant_id }, ReturnValues='ALL_OLD') if 'Attributes' in response: choice_algorithm.on_participant_deletion(wheel, response['Attributes'])
def suggest_participant(event): """ Returns a suggested participant to be selected by the next wheel spin :param event: Lambda event containing the API Gateway request path parameter wheel_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) }, } :return: response dictionary containing a selected participant_id { "body": { "participant_id": string ID of the suggested participant (DDB Hash Key), "rigged": True (if rigged, otherwise this key is not present) } } """ wheel_id = event['pathParameters']['wheel_id'] wheel = Wheel.get_existing_item(Key={'id': wheel_id}) if 'rigging' in wheel: participant_id = wheel['rigging']['participant_id'] # Use rigging only if the rigged participant is still available if 'Item' in WheelParticipant.get_item(Key={ 'wheel_id': wheel_id, 'id': participant_id }): return_value = {'participant_id': participant_id} # Only return rigged: True if we're not using hidden rigging if not wheel['rigging'].get('hidden', False): return_value['rigged'] = True return return_value return {'participant_id': choice_algorithm.suggest_participant(wheel)}
def select_participant(event): """ Indicates selection of a participant by the wheel. This will cause updates to the weights for all participants or removal of rigging if the wheel is rigged. :param event: Lambda event containing the API Gateway request path parameters wheel_id and participant_id { "pathParameters": { "wheel_id": string ID of the wheel to rig (DDB Hash Key) "participant_id": string ID of the participant to rig (DDB Hash Key) }, } :return: response dictionary """ wheel_id = event['pathParameters']['wheel_id'] participant_id = event['pathParameters']['participant_id'] wheel = Wheel.get_existing_item(Key={'id': wheel_id}) participant = WheelParticipant.get_existing_item(Key={ 'id': participant_id, 'wheel_id': wheel_id }) choice_algorithm.select_participant(wheel, participant) # Undo any rigging that has been set up Wheel.update_item(Key={'id': wheel['id']}, UpdateExpression='remove rigging')
def list_participants(event): """ Gets the participants for the specified wheel_id :param event: Lambda event containing the API Gateway request path parameter wheel_id { "pathParameters": { "wheel_id": string ID of the wheel (DDB Hash Key) }, } :return: response dictionary containing a list of participants { "body": [ participant1, participant2, ... participantn, ] } """ wheel_id = event['pathParameters']['wheel_id'] # Make sure the wheel exists Wheel.get_existing_item(Key={'id': wheel_id}) return list( WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel_id)))
def on_participant_deletion(wheel, participant): """ Normalize the remaining participant weights to account for participant removal. The ratio is based on the following: 1) The participant should be at weight=1 when it leaves the system (which is the same as it arrived) 2) That difference should be split by the remaining participants proportional by weight This ensures that 'weight=0' participants are still at weight=0 and that the sum of all weights is equal to the number of participants, so new additions are treated fairly :param wheel: Wheel dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "participant_count": number of participants in the wheel, } :param participant: Participant dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "url": Participant's URL, "wheel_id": string ID of the wheel the participant belongs to, } :return: None """ total_weight = participant['weight'] for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): total_weight += p['weight'] weight = participant['weight'] remaining_weight = total_weight - weight # <-- no longer presumes existing weight balance via 'int(participant_count)' ratio = (1 + ((weight - 1) / remaining_weight)) if ( remaining_weight != 0) else 1 num_participants = Decimal(0) with WheelParticipant.batch_writer() as batch: for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): if p['id'] != participant['id']: # This is cast to a string before turning into a decimal because of rounding/inexact guards in boto3 p['weight'] = Decimal( str(float(p['weight']) * float(ratio))) if (remaining_weight != 0) else 1 batch.put_item(Item=p) num_participants = num_participants + 1 Wheel.update_item(Key={'id': wheel['id']}, **to_update_kwargs( {'participant_count': num_participants}))
def select_participant(wheel, participant): """ Register the selection of a participant by updating the weights of all participants for a given wheel :param wheel: Wheel dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "participant_count": number of participants in the wheel, } :param participant: Participant dictionary: { "id": string ID of the participant (DDB Hash Key), "name": string name of the participant, "url": Participant's URL, "wheel_id": string ID of the wheel the participant belongs to, "weight": participant's weight in the selection algorithm } :return: None """ num_participants = 0 total_weight = Decimal(0) for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): num_participants = num_participants + 1 total_weight += p['weight'] # Factor is the number by which all weights must be multiplied # so total weight will be equal to the number of participants. factor = Decimal(num_participants) / total_weight if num_participants > 1: weight_share = participant['weight'] / Decimal(num_participants - 1) with WheelParticipant.batch_writer() as batch: # Redistribute and normalize the weights. for p in WheelParticipant.iter_query( KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): if p['id'] == participant['id']: p['weight'] = 0 else: p['weight'] += Decimal(weight_share) p['weight'] *= factor batch.put_item(Item=p) Wheel.update_item(Key={'id': wheel['id']}, **to_update_kwargs( {'participant_count': num_participants}))
def reset_wheel(wheel): """ Resets the weights of all participants in the wheel and updates the wheel's participant count :param wheel: Wheel dictionary: { "id": string ID of the wheel (DDB Hash Key), "name": string name of the wheel, "participant_count": number of participants in the wheel, } :return: None """ count = 0 with WheelParticipant.batch_writer() as batch: for p in WheelParticipant.iter_query(KeyConditionExpression=Key('wheel_id').eq(wheel['id'])): p['weight'] = get_sub_wheel_size(p['name']) batch.put_item(Item=p) count += 1 Wheel.update_item(Key={'id': wheel['id']}, **to_update_kwargs({'participant_count': count}))
def set_up_test(setup_data, mock_participant_table): # Select a participant to take everyone off their 1.0 scores. choice_algorithm.select_participant(setup_data['wheel'], setup_data['participants'][0]) # Adjust participants to different weights to take the wheel out of balance. participants = mock_participant_table.query(KeyConditionExpression=Key( 'wheel_id').eq(setup_data['wheel']['id']))['Items'] with WheelParticipant.batch_writer() as batch: for p in participants: p['weight'] += Decimal(.15) batch.put_item(Item=p) # Confirm that the wheel is out of balance. participants = mock_participant_table.query(KeyConditionExpression=Key( 'wheel_id').eq(setup_data['wheel']['id']))['Items'] participant_weights = [ participant['weight'] for participant in participants ] total_weight = Decimal(0) for weight in participant_weights: total_weight += weight assert abs(total_weight - Decimal(8.05)) < epsilon