diff --git a/lib/cadet/assessments/answer.ex b/lib/cadet/assessments/answer.ex index 1035591ab..933c9603a 100644 --- a/lib/cadet/assessments/answer.ex +++ b/lib/cadet/assessments/answer.ex @@ -16,6 +16,7 @@ defmodule Cadet.Assessments.Answer do schema "answers" do # used to compare answers with others field(:relative_score, :float, default: 0.0) + field(:popular_score, :float, default: 0.0) field(:xp, :integer, default: 0) field(:xp_adjustment, :integer, default: 0) field(:comments, :string) @@ -122,4 +123,12 @@ defmodule Cadet.Assessments.Answer do answer |> cast(contest_score_param, [:relative_score]) end + + @doc """ + Used to update popular_score of answer to contest_score + """ + def popular_score_update_changeset(answer, popular_score_param) do + answer + |> cast(popular_score_param, [:popular_score]) + end end diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 3e88fac0a..7203d4e4b 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1006,6 +1006,18 @@ defmodule Cadet.Assessments do # fetch top 10 contest voting entries with the contest question id question_id = fetch_associated_contest_question_id(course_id, q) + # fetch top 10 contest coting entries with contest question id based on popular score + popular_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_popular_score_answers(question_id, 10) + else + [] + end + end + leaderboard_results = if is_nil(question_id) do [] @@ -1025,6 +1037,10 @@ defmodule Cadet.Assessments do :contest_leaderboard, leaderboard_results ) + |> Map.put( + :popular_leaderboard, + popular_results + ) Map.put(q, :question, voting_question) else @@ -1097,6 +1113,37 @@ defmodule Cadet.Assessments do |> Repo.all() end + @doc """ + Fetches top answers for the given question, based on the contest popular_score + + Used for contest leaderboard fetching + """ + def fetch_top_popular_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :popular_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + popular_score: a.popular_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + @doc """ Computes rolling leaderboard for contest votes that are still open. """ @@ -1181,6 +1228,7 @@ defmodule Cadet.Assessments do |> Repo.get_by(id: contest_voting_question_id) entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider) + normalized_scores = map_eligible_votes_to_popular_score(eligible_votes, token_divider) entry_scores |> Enum.map(fn {ans_id, relative_score} -> @@ -1195,6 +1243,20 @@ defmodule Cadet.Assessments do end) |> Enum.reduce(Multi.new(), &Multi.append/2) |> Repo.transaction() + + normalized_scores + |> Enum.map(fn {ans_id, popular_score} -> + %Answer{id: ans_id} + |> Answer.popular_score_update_changeset(%{ + popular_score: popular_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() end defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do @@ -1220,14 +1282,46 @@ defmodule Cadet.Assessments do ) end + defp map_eligible_votes_to_popular_score(eligible_votes, token_divider) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, + calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider)} + end + ) + end + # Calculate the score based on formula # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score = + calculate_normalized_score(sum_of_scores, number_of_voters, tokens, token_divider) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider)) end + # Calculate the normalized score based on formula + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_normalized_score(sum_of_scores, number_of_voters, _tokens, _token_divider) do + sum_of_scores / number_of_voters / 10 * 100 + end + @doc """ Function returning submissions under a grader. This function returns only the fields that are exposed in the /grading endpoint. @@ -1509,7 +1603,8 @@ defmodule Cadet.Assessments do |> Enum.map(fn ans -> if ans.question.type == :voting do empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + empty_popular_leaderboard = Map.put(empty_contest_entries, :popular_leaderboard, []) + empty_contest_leaderboard = Map.put(empty_popular_leaderboard, :contest_leaderboard, []) question = Map.put(ans.question, :question, empty_contest_leaderboard) Map.put(ans, :question, question) else diff --git a/lib/cadet_web/helpers/assessments_helpers.ex b/lib/cadet_web/helpers/assessments_helpers.ex index a77e0a618..57d99f0a9 100644 --- a/lib/cadet_web/helpers/assessments_helpers.ex +++ b/lib/cadet_web/helpers/assessments_helpers.ex @@ -113,6 +113,18 @@ defmodule CadetWeb.AssessmentsHelpers do ) end + defp build_popular_leaderboard_entry(leaderboard_ans) do + Map.put( + transform_map_for_view(leaderboard_ans, %{ + submission_id: :submission_id, + answer: :answer, + student_name: :student_name + }), + "final_score", + Float.round(leaderboard_ans.popular_score, 2) + ) + end + defp build_choice(choice) do transform_map_for_view(choice, %{ id: "choice_id", @@ -183,6 +195,10 @@ defmodule CadetWeb.AssessmentsHelpers do scoreLeaderboard: &Enum.map(&1[:contest_leaderboard], fn entry -> build_contest_leaderboard_entry(entry) + end), + popularVoteLeaderboard: + &Enum.map(&1[:popular_leaderboard], fn entry -> + build_popular_leaderboard_entry(entry) end) }) end diff --git a/priv/repo/migrations/20240213081500_answers_add_popular_score_column.exs b/priv/repo/migrations/20240213081500_answers_add_popular_score_column.exs new file mode 100644 index 000000000..ffeae3cbe --- /dev/null +++ b/priv/repo/migrations/20240213081500_answers_add_popular_score_column.exs @@ -0,0 +1,9 @@ +defmodule Cadet.Repo.Migrations.AnswersAddPopularScoreColumn do + use Ecto.Migration + + def change do + alter table("answers") do + add(:popular_score, :float, default: 0.0) + end + end +end diff --git a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs index 05d926fb3..db80273a2 100644 --- a/test/cadet_web/admin_controllers/admin_grading_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_grading_controller_test.exs @@ -400,7 +400,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "autogradingResults" => &1.autograding_results, "answer" => nil, "contestEntries" => [], - "scoreLeaderboard" => [] + "scoreLeaderboard" => [], + "popularVoteLeaderboard" => [] }, "grade" => %{ "xp" => &1.xp, @@ -1075,7 +1076,8 @@ defmodule CadetWeb.AdminGradingControllerTest do "autogradingResults" => &1.autograding_results, "answer" => nil, "contestEntries" => [], - "scoreLeaderboard" => [] + "scoreLeaderboard" => [], + "popularVoteLeaderboard" => [] }, "grade" => %{ "xp" => &1.xp, diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 02bfc4603..f9251d09b 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -444,8 +444,10 @@ defmodule CadetWeb.AssessmentsControllerTest do expected_voting_questions |> Enum.zip(contests_entries) |> Enum.map(fn {question, contest_entries} -> - question = Map.put(question, "contestEntries", contest_entries) - Map.put(question, "scoreLeaderboard", []) + question + |> Map.put("contestEntries", contest_entries) + |> Map.put("scoreLeaderboard", []) + |> Map.put("popularVoteLeaderboard", []) end) expected_questions =