from otree.api import ( models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, Page, ) import json, math, random doc = '' class C(BaseConstants): NAME_IN_URL = 'threshold_hiring' PLAYERS_PER_GROUP = None NUM_PRACTICE_ROUNDS = 3 NUM_TASK_ROUNDS = 8 NUM_ROUNDS = NUM_PRACTICE_ROUNDS + NUM_TASK_ROUNDS N_CANDIDATES = 10 TOTAL_CLICKS = 20 PRIOR_MEAN = 50 TOP_K_LOW = 3 TOP_K_HIGH = 6 SIGNAL_SD = 15 INTERVIEW_SD = 9 CANDIDATE_LABELS = ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J') class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): # Condition assignment condition = models.StringField() # 'aware' | 'blind' # Per-round Stage 1 budget usage. Resets automatically each round. stage1_clicks_used = models.IntegerField(initial=0) posterior_means = models.LongStringField(blank=True) # Per-round history and summaries round_history = models.LongStringField(blank=True) practice_round_history = models.LongStringField(blank=True) total_correct_hires = models.IntegerField(blank=True) top_catch_rate = models.FloatField(blank=True) avg_quality_loss = models.FloatField(blank=True) # Exit survey strategy_explanation = models.LongStringField( label='', blank=False, ) # built-in hook function(s) (called automatically by oTree) def creating_session(subsession): for player in subsession.get_players(): # Between-subject condition player.condition = 'aware' if player.id_in_subsession % 2 == 1 else 'blind' if subsession.round_number == 1: player.participant.vars['session_uid'] = f"{subsession.round_number}-{random.getrandbits(64)}" player.participant.vars['round_history'] = [] player.participant.vars['practice_round_history'] = [] init_practice_state(player, practice_round_number=1) def init_practice_state(player, practice_round_number=1): session_uid = player.participant.vars.get('session_uid') qualities = [random.randint(0, 100) for _ in range(C.N_CANDIDATES)] order = list(range(C.N_CANDIDATES)) random.shuffle(order) top_k = random.randint(C.TOP_K_LOW, C.TOP_K_HIGH) player.participant.vars['practice_state'] = { 'session_uid': session_uid, 'practice_round_number': practice_round_number, 'top_k': top_k, 'true_qualities': qualities, 'display_order': order, 'clicks_per_candidate': [0] * C.N_CANDIDATES, 'signals_per_candidate': [[] for _ in range(C.N_CANDIDATES)], 'stage1_scores': [None] * C.N_CANDIDATES, 'advanced_indices': [], 'stage2_signals': {}, 'stage2_scores': {}, 'hire_choice': None, 'is_practice': True, } player.participant.vars['practice_loaded_round'] = practice_round_number def is_practice_round(player): return player.round_number <= C.NUM_PRACTICE_ROUNDS def is_main_task_round(player): return C.NUM_PRACTICE_ROUNDS < player.round_number <= C.NUM_ROUNDS def draw_signal(true_q, sd=C.SIGNAL_SD): raw = true_q + random.gauss(0, sd) return int(round(max(0, raw))) def candidate_label(slot): if slot < len(C.CANDIDATE_LABELS): return C.CANDIDATE_LABELS[slot] return f'C{slot + 1}' def compute_auto_hire(state): advanced = state.get('advanced_indices', []) if not advanced: state['stage2_posterior_means'] = {} return None qualities = state['true_qualities'] clicks = state['clicks_per_candidate'] scores = state['stage1_scores'] s2_signals = state.get('stage2_signals', {}) for idx in advanced: key = str(idx) if key not in s2_signals: s2_signals[key] = draw_signal(qualities[idx], sd=C.INTERVIEW_SD) state['stage2_signals'] = s2_signals sig_var = C.SIGNAL_SD ** 2 int_var = C.INTERVIEW_SD ** 2 grid = range(0, 101) posterior_means = {} best_idx = None best_key = None for idx in advanced: n_i = clicks[idx] s1_i = scores[idx] if scores[idx] is not None else 0 z_i = s2_signals[str(idx)] log_lik = [-0.5 * (z_i - q) ** 2 / int_var for q in grid] if n_i > 0: log_lik = [val - 0.5 * n_i * (s1_i - q) ** 2 / sig_var for val, q in zip(log_lik, grid)] max_log_lik = max(log_lik) weights = [math.exp(val - max_log_lik) for val in log_lik] weight_sum = sum(weights) if weight_sum <= 0: pm = 0.0 else: pm = sum(weight * q for weight, q in zip(weights, grid)) / weight_sum posterior_means[str(idx)] = pm key = (pm, -idx) if best_key is None or key > best_key: best_key = key best_idx = idx state['stage2_posterior_means'] = posterior_means return best_idx def select_stage2_hire(advanced_indices, posterior_means): """Return exactly one hired candidate using posterior mean, then index tie-break.""" best_idx = None best_key = None for idx in advanced_indices: pm = posterior_means.get(str(idx), 0) key = (pm, -idx) if best_key is None or key > best_key: best_key = key best_idx = idx return best_idx def get_round_history(player): """Return the round_history list, or [] if not yet set.""" history = player.participant.vars.get('round_history') if history is None: if player.round_history: return json.loads(player.round_history) return [] if isinstance(history, str): return json.loads(history) return history def save_round_history(player, history): player.participant.vars['round_history'] = history player.round_history = json.dumps(history) def get_current_round_number(player): """1-indexed round number = number of completed rounds + 1.""" return len(get_round_history(player)) + 1 def get_stage1_budget_remaining(player): used = player.field_maybe_none('stage1_clicks_used') or 0 return max(0, C.TOTAL_CLICKS - used) def init_round_state(player): """ Draw fresh candidates for the current round. Store working state in participant.vars so Stage1 live_method can access it without re-reading from player fields on every click. """ qualities = [random.randint(0, 100) for _ in range(C.N_CANDIDATES)] order = list(range(C.N_CANDIDATES)) random.shuffle(order) top_k = random.randint(C.TOP_K_LOW, C.TOP_K_HIGH) player.participant.vars['current_round_state'] = { 'session_uid': player.participant.vars.get('session_uid'), 'round_number': get_current_round_number(player), 'top_k': top_k, 'true_qualities': qualities, 'display_order': order, 'clicks_per_candidate': [0] * C.N_CANDIDATES, 'signals_per_candidate': [[] for _ in range(C.N_CANDIDATES)], 'stage1_scores': [None] * C.N_CANDIDATES, 'advanced_indices': [], 'stage2_signals': {}, 'stage2_scores': {}, 'hire_choice': None, 'is_correct_hire': None, 'quality_of_hire': None, 'best_available_quality': None, 'quality_loss': None, } def get_current_state(player): state = player.participant.vars.get('current_round_state') if state is None or state.get('session_uid') != player.participant.vars.get('session_uid'): init_round_state(player) state = player.participant.vars.get('current_round_state') return state def save_current_state(player, state): player.participant.vars['current_round_state'] = state def get_practice_state(player): """ Return practice_state safely. If missing, stale, or not yet initialized for the current practice round, regenerate it so each practice round starts with a fresh click budget. """ expected_round = min(player.round_number, C.NUM_PRACTICE_ROUNDS) state = player.participant.vars.get('practice_state') loaded_round = player.participant.vars.get('practice_loaded_round') if ( state is None or state.get('session_uid') != player.participant.vars.get('session_uid') or state.get('practice_round_number') != expected_round or loaded_round != expected_round ): init_practice_state(player, practice_round_number=expected_round) state = player.participant.vars.get('practice_state') return state def commit_round(player): """ Move current_round_state into round_history and clear working state. Called in Stage2.before_next_page after hire decision is made. """ state = get_current_state(player) history = get_round_history(player) history.append(state) save_round_history(player, history) player.participant.vars.pop('current_round_state', None) class InstructionsIntro(Page): @staticmethod def is_displayed(player): return player.round_number == 1 @staticmethod def vars_for_template(player): # Round 1 now belongs to practice, so read top_k from practice state. state = get_practice_state(player) top_k = state['top_k'] return dict( condition=player.condition, top_k_low=C.TOP_K_LOW, top_k_high=C.TOP_K_HIGH, top_k=top_k if player.condition == 'aware' else None, total_clicks=C.TOTAL_CLICKS, n_candidates=C.N_CANDIDATES, num_task_rounds=C.NUM_TASK_ROUNDS, resume_sd=C.SIGNAL_SD, interview_sd=C.INTERVIEW_SD, ) class PracticeStage1(Page): @staticmethod def is_displayed(player): return is_practice_round(player) @staticmethod def vars_for_template(player): state = get_practice_state(player) top_k = state['top_k'] order = state['display_order'] scores = state['stage1_scores'] clicks = state['clicks_per_candidate'] signals_list = state['signals_per_candidate'] rows = [] for slot, idx in enumerate(order): rows.append(dict( is_divider=False, index=idx, candidate_index=idx, slot=slot, label=candidate_label(slot), score=scores[idx] if clicks[idx] > 0 else None, clicks=clicks[idx], signals=signals_list[idx], )) rows.sort(key=lambda r: ( r['score'] is None, -(r['score'] if r['score'] is not None else 0), (r['slot'] if r['score'] is None else r['index']) )) budget_remaining = get_stage1_budget_remaining(player) budget_remaining_pct = round(budget_remaining / C.TOTAL_CLICKS * 100) return dict( condition=player.condition, top_k=top_k if player.condition == 'aware' else None, total_clicks=C.TOTAL_CLICKS, budget_remaining=budget_remaining, budget_remaining_pct=budget_remaining_pct, rows=rows, current_round=f'Practice {player.round_number}', current_round_number=player.round_number, num_practice_rounds=C.NUM_PRACTICE_ROUNDS, num_task_rounds=C.NUM_TASK_ROUNDS, round_indices=list(range(C.NUM_PRACTICE_ROUNDS)), is_practice=True, task_reminder_open=(player.round_number == 1), ) @staticmethod def live_method(player, data): if not isinstance(data, dict) or 'candidate_index' not in data: return {player.id_in_group: {'error': 'bad_payload'}} idx = int(data['candidate_index']) if idx < 0 or idx >= C.N_CANDIDATES: return {player.id_in_group: {'error': 'bad_index'}} state = get_practice_state(player) clicks = state['clicks_per_candidate'] signals = state['signals_per_candidate'] scores = state['stage1_scores'] qualities = state['true_qualities'] budget_remaining = get_stage1_budget_remaining(player) if budget_remaining <= 0: return {player.id_in_group: {'error': 'no_budget'}} sig = draw_signal(qualities[idx]) signals[idx].append(sig) clicks[idx] += 1 scores[idx] = round(sum(signals[idx]) / len(signals[idx])) player.stage1_clicks_used = (player.field_maybe_none('stage1_clicks_used') or 0) + 1 budget_remaining = get_stage1_budget_remaining(player) state['clicks_per_candidate'] = clicks state['signals_per_candidate'] = signals state['stage1_scores'] = scores player.participant.vars['practice_state'] = state return {player.id_in_group: { 'candidate_index': idx, 'new_signal': sig, 'new_score': scores[idx], 'budget_remaining': budget_remaining, }} @staticmethod def before_next_page(player, timeout_happened): state = get_practice_state(player) scores = state['stage1_scores'] clicks = state['clicks_per_candidate'] top_k = state['top_k'] scored = [(scores[i], -i, i) for i in range(C.N_CANDIDATES) if clicks[i] > 0] scored.sort(reverse=True) state['advanced_indices'] = [i for _, _, i in scored[:top_k]] player.participant.vars['practice_state'] = state class PracticeStage2(Page): @staticmethod def is_displayed(player): return is_practice_round(player) @staticmethod def vars_for_template(player): state = get_practice_state(player) top_k = state['top_k'] qualities = state['true_qualities'] clicks = state['clicks_per_candidate'] scores = state['stage1_scores'] advanced = state['advanced_indices'] order = state['display_order'] hired = compute_auto_hire(state) player.participant.vars['practice_state'] = state s2_signals = state.get('stage2_signals', {}) posterior_means = state.get('stage2_posterior_means', {}) hired_idx = select_stage2_hire(advanced, posterior_means) label_map = {idx: candidate_label(slot) for slot, idx in enumerate(order)} candidates = [] for idx in advanced: candidates.append({ 'index': idx, 'label': label_map[idx], 'resume_score': scores[idx], 'n_signals': clicks[idx], 'interview_score': s2_signals.get(str(idx)), 'posterior_mean': round(posterior_means.get(str(idx), 0), 1), 'is_hired': (hired_idx == idx), }) candidates.sort(key=lambda c: c['label']) budget_remaining = get_stage1_budget_remaining(player) budget_remaining_pct = round(budget_remaining / C.TOTAL_CLICKS * 100) return dict( candidates=candidates, condition=player.condition, top_k=top_k if player.condition == 'aware' else None, interview_sd=C.INTERVIEW_SD, total_clicks=C.TOTAL_CLICKS, budget_remaining=budget_remaining, budget_remaining_pct=budget_remaining_pct, has_advanced=(len(advanced) > 0), current_round=f'Practice {player.round_number}', current_round_number=player.round_number, num_practice_rounds=C.NUM_PRACTICE_ROUNDS, num_task_rounds=C.NUM_TASK_ROUNDS, round_indices=list(range(C.NUM_PRACTICE_ROUNDS)), is_practice=True, ) @staticmethod def before_next_page(player, timeout_happened): state = get_practice_state(player) qualities = state['true_qualities'] advanced = state['advanced_indices'] hired = compute_auto_hire(state) player.participant.vars['practice_state'] = state player.posterior_means = json.dumps(state.get('stage2_posterior_means', {})) practice_history = player.participant.vars.get('practice_round_history', []) if isinstance(practice_history, str): practice_history = json.loads(practice_history) if not advanced: entry = { 'round_number': player.round_number, 'true_qualities': qualities, 'display_order': state['display_order'], 'advanced_indices': advanced, 'hire_choice': None, 'is_correct_hire': False, 'quality_of_hire': 0, 'best_available_quality': 0, 'quality_loss': 0, } practice_history.append(entry) player.practice_round_history = json.dumps(practice_history) player.participant.vars['practice_round_history'] = practice_history if player.round_number < C.NUM_PRACTICE_ROUNDS: init_practice_state(player, practice_round_number=player.round_number + 1) return best_idx = max(advanced, key=lambda i: qualities[i]) entry = { 'round_number': player.round_number, 'true_qualities': qualities, 'display_order': state['display_order'], 'advanced_indices': advanced, 'hire_choice': hired, 'is_correct_hire': hired == best_idx, 'quality_of_hire': qualities[hired] if hired is not None else 0, 'best_available_quality': qualities[best_idx], 'quality_loss': qualities[best_idx] - qualities[hired] if hired is not None else qualities[best_idx], } practice_history.append(entry) player.practice_round_history = json.dumps(practice_history) player.participant.vars['practice_round_history'] = practice_history if player.round_number < C.NUM_PRACTICE_ROUNDS: init_practice_state(player, practice_round_number=player.round_number + 1) class PracticeRoundFeedback(Page): @staticmethod def is_displayed(player): if player.round_number != C.NUM_PRACTICE_ROUNDS: return False # Read from participant.vars — always in sync, never stale history = player.participant.vars.get('practice_round_history') if not history: return False if isinstance(history, str): history = json.loads(history) return len(history) == C.NUM_PRACTICE_ROUNDS @staticmethod def vars_for_template(player): history = player.participant.vars.get('practice_round_history') if not history: if player.practice_round_history: history = json.loads(player.practice_round_history) else: return dict( condition=player.condition, rounds=[], practice_total_correct=0, practice_top_catch_rate=0, num_task_rounds=C.NUM_TASK_ROUNDS, num_practice_rounds=C.NUM_PRACTICE_ROUNDS, ) if isinstance(history, str): history = json.loads(history) rounds = [] practice_total_correct = sum(1 for r in history if r['is_correct_hire']) practice_top_catch_rate = round(practice_total_correct / len(history) * 100) if history else 0 for entry in history: order = entry['display_order'] label_map = {idx: candidate_label(slot) for slot, idx in enumerate(order)} hired_label = label_map.get(entry['hire_choice'], '—') if entry.get('hire_choice') is not None else '—' best_idx = max(entry['advanced_indices'], key=lambda i: entry['true_qualities'][i]) if entry['advanced_indices'] else None best_label = label_map.get(best_idx, '—') if best_idx is not None else '—' rounds.append(dict( round_number=entry['round_number'], is_correct_hire=entry['is_correct_hire'], hired_label=hired_label, best_label=best_label, quality_of_hire=entry['quality_of_hire'], best_available_quality=entry['best_available_quality'], quality_loss=entry['quality_loss'], )) return dict( condition=player.condition, rounds=rounds, practice_total_correct=practice_total_correct, practice_top_catch_rate=practice_top_catch_rate, num_task_rounds=C.NUM_TASK_ROUNDS, num_practice_rounds=C.NUM_PRACTICE_ROUNDS, ) class Stage1(Page): @staticmethod def is_displayed(player): return is_main_task_round(player) @staticmethod def vars_for_template(player): if 'current_round_state' not in player.participant.vars: init_round_state(player) state = get_current_state(player) top_k = state['top_k'] order = state['display_order'] scores = state['stage1_scores'] clicks = state['clicks_per_candidate'] signals_list = state['signals_per_candidate'] rows = [] for slot, idx in enumerate(order): rows.append(dict( is_divider=False, index=idx, candidate_index=idx, slot=slot, label=candidate_label(slot), score=scores[idx] if clicks[idx] > 0 else None, clicks=clicks[idx], signals=signals_list[idx], )) rows.sort(key=lambda r: ( r['score'] is None, -(r['score'] if r['score'] is not None else 0), (r['slot'] if r['score'] is None else r['index']) )) budget_remaining = max(0, C.TOTAL_CLICKS - sum(clicks)) budget_remaining_pct = round(budget_remaining / C.TOTAL_CLICKS * 100) return dict( condition=player.condition, top_k=top_k if player.condition == 'aware' else None, total_clicks=C.TOTAL_CLICKS, budget_remaining=budget_remaining, budget_remaining_pct=budget_remaining_pct, rows=rows, current_round=state['round_number'], current_round_number=state['round_number'], num_task_rounds=C.NUM_TASK_ROUNDS, round_indices=list(range(C.NUM_TASK_ROUNDS)), task_reminder_open=(state['round_number'] == 1), ) @staticmethod def live_method(player, data): if not isinstance(data, dict) or 'candidate_index' not in data: return {player.id_in_group: {'error': 'bad_payload'}} idx = int(data['candidate_index']) if idx < 0 or idx >= C.N_CANDIDATES: return {player.id_in_group: {'error': 'bad_index'}} state = get_current_state(player) clicks = state['clicks_per_candidate'] signals = state['signals_per_candidate'] scores = state['stage1_scores'] qualities = state['true_qualities'] # Budget check budget_remaining = get_stage1_budget_remaining(player) if budget_remaining <= 0: return {player.id_in_group: {'error': 'no_budget'}} # Draw signal and update sig = draw_signal(qualities[idx]) signals[idx].append(sig) clicks[idx] += 1 scores[idx] = round(sum(signals[idx]) / len(signals[idx])) player.stage1_clicks_used = (player.field_maybe_none('stage1_clicks_used') or 0) + 1 budget_remaining = get_stage1_budget_remaining(player) state['clicks_per_candidate'] = clicks state['signals_per_candidate'] = signals state['stage1_scores'] = scores save_current_state(player, state) return {player.id_in_group: { 'candidate_index': idx, 'new_signal': sig, 'new_score': scores[idx], 'budget_remaining': budget_remaining, }} @staticmethod def before_next_page(player, timeout_happened): state = get_current_state(player) scores = state['stage1_scores'] clicks = state['clicks_per_candidate'] top_k = state['top_k'] scored = [(scores[i], -i, i) for i in range(C.N_CANDIDATES) if clicks[i] > 0] scored.sort(reverse=True) advanced = [i for _, _, i in scored[:top_k]] state['advanced_indices'] = advanced save_current_state(player, state) class AutoHire(Page): template_name = 'threshold_hiring/Stage2.html' @staticmethod def is_displayed(player): return is_main_task_round(player) @staticmethod def vars_for_template(player): if 'current_round_state' not in player.participant.vars: init_round_state(player) state = get_current_state(player) top_k = state['top_k'] qualities = state['true_qualities'] clicks = state['clicks_per_candidate'] scores = state['stage1_scores'] advanced = state['advanced_indices'] order = state['display_order'] hired = compute_auto_hire(state) save_current_state(player, state) s2_signals = state.get('stage2_signals', {}) posterior_means = state.get('stage2_posterior_means', {}) hired_idx = select_stage2_hire(advanced, posterior_means) # Build candidate list in display order, only advancing candidates label_map = {idx: candidate_label(slot) for slot, idx in enumerate(order)} candidates = [] for idx in advanced: candidates.append({ 'index': idx, 'label': label_map[idx], 'resume_score': scores[idx], 'n_signals': clicks[idx], 'interview_score': s2_signals.get(str(idx)), 'posterior_mean': round(posterior_means.get(str(idx), 0), 1), 'is_hired': (hired_idx == idx), }) candidates.sort(key=lambda c: c['label']) return dict( candidates=candidates, condition=player.condition, top_k=top_k if player.condition == 'aware' else None, interview_sd=C.INTERVIEW_SD, has_advanced=(len(advanced) > 0), current_round=state['round_number'], num_task_rounds=C.NUM_TASK_ROUNDS, round_indices=list(range(C.NUM_TASK_ROUNDS)), ) @staticmethod def before_next_page(player, timeout_happened): state = get_current_state(player) qualities = state['true_qualities'] advanced = state['advanced_indices'] hired = compute_auto_hire(state) player.posterior_means = json.dumps(state.get('stage2_posterior_means', {})) if not advanced: state['hire_choice'] = None state['is_correct_hire'] = False state['quality_of_hire'] = 0 state['best_available_quality'] = 0 state['quality_loss'] = 0 save_current_state(player, state) commit_round(player) if get_current_round_number(player) <= C.NUM_TASK_ROUNDS: init_round_state(player) return best_idx = max(advanced, key=lambda i: qualities[i]) state['hire_choice'] = hired state['is_correct_hire'] = (hired == best_idx) state['quality_of_hire'] = qualities[hired] state['best_available_quality'] = qualities[best_idx] state['quality_loss'] = qualities[best_idx] - qualities[hired] save_current_state(player, state) commit_round(player) if get_current_round_number(player) <= C.NUM_TASK_ROUNDS: init_round_state(player) class RoundFeedback(Page): @staticmethod def is_displayed(player): history = get_round_history(player) # Show neutral transition only between main task rounds, not after final round. return len(history) > 0 and len(history) < C.NUM_TASK_ROUNDS and is_main_task_round(player) @staticmethod def vars_for_template(player): history = get_round_history(player) rounds_done = len(history) rounds_remaining = C.NUM_TASK_ROUNDS - len(history) return dict( condition=player.condition, round_number=rounds_done, next_round_number=rounds_done + 1, num_task_rounds=C.NUM_TASK_ROUNDS, rounds_done=rounds_done, rounds_remaining=rounds_remaining, ) class LoopBack(Page): timeout_seconds = 0 template_name = 'threshold_hiring/LoopBack.html' @staticmethod def is_displayed(player): return player.round_number < C.NUM_ROUNDS class Results(Page): @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): history = get_round_history(player) total_correct = sum(1 for entry in history if entry['is_correct_hire']) avg_loss = round(sum(entry['quality_loss'] for entry in history) / len(history), 1) if history else 0 top_catch_rate = round(total_correct / len(history) * 100) if history else 0 player.total_correct_hires = total_correct player.top_catch_rate = (total_correct / C.NUM_TASK_ROUNDS) if C.NUM_TASK_ROUNDS else 0 player.avg_quality_loss = avg_loss # Bonus excludes practice rounds by construction: history stores only main rounds. player.payoff = total_correct rounds = [] for entry in history: order = entry['display_order'] label_map = {idx: candidate_label(slot) for slot, idx in enumerate(order)} hired_label = label_map.get(entry.get('hire_choice'), '?') best_label = '?' if entry.get('advanced_indices'): best_idx = max(entry['advanced_indices'], key=lambda i: entry['true_qualities'][i]) best_label = label_map.get(best_idx, '?') rounds.append({ 'round_number': entry['round_number'], 'top_k': entry.get('top_k'), 'hired_label': hired_label, 'quality_of_hire': entry['quality_of_hire'], 'best_label': best_label, 'best_available_quality': entry['best_available_quality'], 'quality_loss': entry['quality_loss'], 'is_correct_hire': entry['is_correct_hire'], }) return dict( condition=player.condition, rounds=rounds, total_correct=total_correct, top_catch_rate=top_catch_rate, avg_loss=avg_loss, bonus_earned=player.payoff, num_task_rounds=C.NUM_TASK_ROUNDS, ) class ExitSurvey(Page): form_model = 'player' form_fields = ['strategy_explanation'] @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): return dict( condition=player.condition, top_k_low=C.TOP_K_LOW, top_k_high=C.TOP_K_HIGH, total_clicks=C.TOTAL_CLICKS, strategy_explanation=player.field_maybe_none('strategy_explanation') or '', ) @staticmethod def error_message(player, values): text = (values.get('strategy_explanation') or '').strip() if len(text) < 1: return 'Please write something about your strategy before continuing.' class ClosingPage(Page): @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): history = get_round_history(player) total_correct = sum(1 for r in history if r['is_correct_hire']) top_catch_rate = round(total_correct / len(history) * 100) if history else 0 return dict( condition=player.condition, top_catch_rate=top_catch_rate, total_correct=total_correct, num_task_rounds=C.NUM_TASK_ROUNDS, ) page_sequence = [ InstructionsIntro, PracticeStage1, PracticeStage2, PracticeRoundFeedback, Stage1, AutoHire, RoundFeedback, LoopBack, Results, ExitSurvey, ClosingPage, ]