Skip to content

Commit

Permalink
Add option to allow bonus points in assessments (#4090)
Browse files Browse the repository at this point in the history
* Initial schema and database update

* Add new field to sync course-db

* Add field to assessment instance as well

* Show bonus points in students' assessment overview

* Update score based on max bonus

* Add missing field

* Ensure max_bonus_points is not above available points in instance

* Add max_bonus_points to API entrypoint for assessment instances

* Docs

* Test (for now with other tests removed)

* Fixed variable name

* Proper test now working

* Example course assessment

* Remove unnecessary clientFilesAssessment in bonus points HW in test

* Minor changes in documentation

* Update in sprocs comments in variable names for clarity

* Remove extra blank line

Co-authored-by: Nathan Walters <nathan@prairielearn.com>

* Properly handle a null max_bonus_points

Co-authored-by: Matthew West <matt@prairielearn.com>

Co-authored-by: Nathan Walters <nathan@prairielearn.com>
Co-authored-by: Matthew West <matt@prairielearn.com>
  • Loading branch information
3 people committed May 29, 2021
1 parent bd4e3aa commit a941343
Show file tree
Hide file tree
Showing 19 changed files with 221 additions and 27 deletions.
1 change: 1 addition & 0 deletions api/v1/endpoints/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ WITH object_data AS (
u.name AS user_name,
coalesce(e.role, 'None'::enum_role) AS user_role,
ai.max_points,
ai.max_bonus_points,
ai.points,
ai.score_perc,
ai.number AS assessment_instance_number,
Expand Down
1 change: 1 addition & 0 deletions database/tables/assessment_instances.pg
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ columns
duration: interval default '00:00:00'::interval
group_id: bigint
id: bigint not null default nextval('assessment_instances_id_seq'::regclass)
max_bonus_points: double precision
max_points: double precision
mode: enum_mode
modified_at: timestamp with time zone not null default CURRENT_TIMESTAMP
Expand Down
1 change: 1 addition & 0 deletions database/tables/assessments.pg
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ columns
deleted_at: timestamp with time zone
group_work: boolean default false
id: bigint not null default nextval('assessments_id_seq'::regclass)
max_bonus_points: double precision
max_points: double precision
mode: enum_mode
multiple_instance: boolean
Expand Down
4 changes: 2 additions & 2 deletions docs/accessControl.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ Mode | When active

## Credit

When the available credit is less than 100%, the percentage score is calculated as `min(credit, points / maxPoints * 100)`. However, the student's percentage score will never descrease, so if they've already earned a higher percentage score then they will keep it. For example, if `credit = 80` and `maxPoints = 10`, then when a student has `points = 8` then they will have a percentage score of 80%, and when they have `points = 9` or `points = 10` they will still have a percentage score of 80%.
When the available credit is less than 100%, the percentage score is calculated as `min(credit, points / maxPoints * 100)`. However, the student's percentage score will never decrease, so if they've already earned a higher percentage score then they will keep it. For example, if `credit = 80` and `maxPoints = 10`, then when a student has `points = 8` then they will have a percentage score of 80%, and when they have `points = 9` or `points = 10` they will still have a percentage score of 80%.

When the available credit is more than 100%, then the percentage score is calculated as `points / maxPoints * 100` when `points < maxPoints`. However, if `points = maxPoints` then the percentage score is taken to be the credit value. For example, if `credit = 120` then the student will see their percentage score rise towards 100% as their `points` increase towards `maxPoints`, and then when their `points` reaches `maxPoints` their percentage score will suddenly jump to 120%.
When the available credit is more than 100%, then the percentage score is calculated as `points / maxPoints * 100` when `points < maxPoints`. However, if `points = maxPoints` then the percentage score is taken to be the credit value. For example, if `credit = 120` then the student will see their percentage score rise towards 100% as their `points` increase towards `maxPoints`, and then when their `points` reaches `maxPoints` their percentage score will suddenly jump to 120%. If [`maxBonusPoints`](assessment.md#assessment-points) is used, and the student is able to obtain points above `maxPoints`, then the student's percentage score will be based on this credit as a percentage, i.e., their score will be computed as `credit * points / maxPoints`.

## Time limits

Expand Down
23 changes: 20 additions & 3 deletions docs/assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,28 @@ An assessment is broken down in to a list of zones, like this:

## Assessment and question instances and resetting assessments

PrairieLearn distinguishes between *assessments* and *assessment instances*. A *assessment* is determined by the code in an `assessments` directory, and is something like "Midterm 1". Given an assessment, PrairieLearn needs to generate the random set of questions and question variants for each student, and it is this selection that is the *assessment instance* for the student. There is only one copy of each assessment, but every student has their own assessment instance. The rules for updating assessment instances differ between `Homework` and `Exam` assessments.
PrairieLearn distinguishes between *assessments* and *assessment instances*. An *assessment* is determined by the code in an `assessments` directory, and is something like "Midterm 1". Given an assessment, PrairieLearn needs to generate the random set of questions and question variants for each student, and it is this selection that is the *assessment instance* for the student. There is only one copy of each assessment, but every student has their own assessment instance. The rules for updating assessment instances differ between `Homework` and `Exam` assessments.

**`Exam` assessment updates:** Exam assessment instances are generated when the student starts the exam, and they are never automatically deleted, regenerated, or updated, even when the original assessment is changed in some way. This is a safety mechanism to avoid having students' assessments changed during an exam. However, if you want to force the regeneration of assessment instances then you can do so with the reset button on instructor view of the assessment. While writing an assessment you might need to do this many times. Once an assessment is live, you should of course be very careful about doing this (basically, dont do it on a production server once an assessment is underway).
**`Exam` assessment updates:** Exam assessment instances are generated when the student starts the exam, and they are never automatically deleted, regenerated, or updated, even when the original assessment is changed in some way. This is a safety mechanism to avoid having students' assessments changed during an exam. However, if you want to force the regeneration of assessment instances then you can do so with the "reset" button on instructor view of the assessment. While writing an assessment you might need to do this many times. Once an assessment is live, you should of course be very careful about doing this (basically, don't do it on a production server once an assessment is underway).

**`Homework` assessment updates:** New questions added to Homeworks will be automatically integrated into student homeworks currently in progress. Updates to `maxPoints` will take effect the next time a student grades a question. A student's “points” and “percentage score” will never decrease.
**`Homework` assessment updates:** New questions added to Homeworks will be automatically integrated into student homeworks currently in progress. Updates to `maxPoints` or `maxBonusPoints` will take effect the next time a student grades a question. A student's "points" and "percentage score" will never decrease.

## Assessment points

A student's percentage score will be determined by the number of points they have obtained, divided by the value of `maxPoints` (subject to the rules associated to [`credit`](accessControl.md#credit) in assessment access rules).

```json
{
"uuid": "cef0cbf3-6458-4f13-a418-ee4d7e7505dd",
"maxPoints": 50,
"maxBonusPoints": 5,
...
}
```

The `maxPoints` determines the number of points a student is required to obtain to get a score of 100%. The percentage score will thus be computed based on the points the student obtained divided by the value of `maxPoints`. If not provided, `maxPoints` is computed based on the maximum number of points that can be obtained from all questions in all zones.

By default, once a student obtains enough points to reach the value of `maxPoints`, any further points do not affect the assessment score. However, if a value is set for `maxBonusPoints`, the student can obtain additional points, up to a total of `maxPoints + maxBonusPoints`. The percentage is still based on `maxPoints`, so the use of `maxBonusPoints` allows students to obtain a percentage above 100%. If `maxBonusPoints` is set, but `maxPoints` is not provided, then `maxPoints` will be computed by subtracting `maxBonusPoints` from the maximum number of points in all questions.

## Multiple-instance versus single-instance assessments

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"title": "PrairieLearn demo questions for Python autograder",
"set": "Homework",
"number": "6",
"maxPoints": 40,
"maxBonusPoints": 3,
"allowAccess": [
{
"mode": "Public",
Expand Down
2 changes: 2 additions & 0 deletions migrations/232_assessments__max_bonus_points__add.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE assessments ADD COLUMN IF NOT EXISTS max_bonus_points DOUBLE PRECISION;
ALTER TABLE assessment_instances ADD COLUMN IF NOT EXISTS max_bonus_points DOUBLE PRECISION;
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@
<div class="col-md-3 col-sm-12">
<% include('../partials/pointsFormatter'); %>
Total points: <%= getStringFromFloat(assessment_instance.max_points) %>
<% if (assessment_instance.max_bonus_points) { %>
<br />(<%= assessment_instance.max_bonus_points %> bonus
point<% if (assessment_instance.max_bonus_points > 1) { %>s<% } %> possible)
<% } %>
</div>
<div class="col-md-9 col-sm-12">
<% if (assessment_instance.open) { %>
Expand All @@ -100,6 +104,10 @@
<div class="col-md-3 col-sm-6">
<% include('../partials/pointsFormatter'); %>
Total points: <%= getStringFromFloat(assessment_instance.points) %>/<%= getStringFromFloat(assessment_instance.max_points) %>
<% if (assessment_instance.max_bonus_points) { %>
<br />(<%= assessment_instance.max_bonus_points %> bonus
point<% if (assessment_instance.max_bonus_points > 1) { %>s<% } %> possible)
<% } %>
</div>
<div class="col-md-3 col-sm-6">
<%- include('../partials/scorebar', {score: assessment_instance.score_perc}) %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
<div class="col-md-3 col-sm-6 col-xs-12">
<%- include('../partials/pointsFormatter'); %>
Total points: <%= getStringFromFloat(assessment_instance.points) %>/<%= assessment_instance.max_points %>
<% if (assessment_instance.max_bonus_points) { %>
<br />(<%= assessment_instance.max_bonus_points %> bonus
point<% if (assessment_instance.max_bonus_points > 1) { %>s<% } %> possible)
<% } %>
</div>
<div class="col-md-3 col-sm-6 col-xs-12">
<%- include('../partials/scorebar', {score: assessment_instance.score_perc}) %>
Expand Down
6 changes: 5 additions & 1 deletion schemas/schemas/infoAssessment.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@
"type": "string"
},
"maxPoints": {
"description": "The maximum number of points that can be earned for this assessment.",
"description": "The number of points that must be earned in this assessment to achieve a score of 100%.",
"type": "number"
},
"maxBonusPoints": {
"description": "The maximum number of additional points that can be earned beyond maxPoints.",
"type": "number"
},
"autoClose": {
Expand Down
16 changes: 7 additions & 9 deletions sprocs/assessment_instances_grade.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ DECLARE
total_points DOUBLE PRECISION;
total_points_in_grading DOUBLE PRECISION;
max_points DOUBLE PRECISION;
max_bonus_points DOUBLE PRECISION;
current_score_perc DOUBLE PRECISION;
max_possible_points DOUBLE PRECISION;
max_possible_score_perc DOUBLE PRECISION;
Expand Down Expand Up @@ -75,27 +76,24 @@ BEGIN
-- #########################################################################
-- compute the total points

SELECT ai.max_points INTO max_points
FROM assessment_instances AS ai
WHERE ai.id = assessment_instance_id;

SELECT ai.score_perc INTO current_score_perc
SELECT ai.max_points, ai.max_bonus_points, ai.score_perc
INTO max_points, max_bonus_points, current_score_perc
FROM assessment_instances AS ai
WHERE ai.id = assessment_instance_id;

-- #########################################################################
-- awarded points and score_perc

-- compute the score in points, maxing out at max_points
points := least(total_points, max_points);
-- compute the score in points, maxing out at max_points + max_bonus_points
points := least(total_points, max_points + coalesce(max_bonus_points, 0));

-- compute the score as a percentage, applying credit bonus/limits
score_perc := points
/ (CASE WHEN max_points > 0 THEN max_points ELSE 1 END) * 100;
IF use_credit < 100 THEN
score_perc := least(score_perc, use_credit);
ELSIF (use_credit > 100) AND (points = max_points) THEN
score_perc := use_credit;
ELSIF (use_credit > 100) AND (points >= max_points) THEN
score_perc := use_credit * score_perc / 100;
END IF;

IF NOT allow_decrease THEN
Expand Down
29 changes: 17 additions & 12 deletions sprocs/assessment_instances_update.sql
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ DECLARE
assessment_type enum_assessment_type;
assessment_instance_open boolean;
new_instance_questions_count integer;
zones_total_max_points double precision;
assessment_max_points double precision;
assessment_max_bonus_points double precision;
old_assessment_instance_max_points double precision;
old_assessment_instance_max_bonus_points double precision;
new_assessment_instance_max_points double precision;
BEGIN
PERFORM assessment_instances_lock(assessment_instance_id);
Expand All @@ -42,10 +45,12 @@ BEGIN
SELECT
c.id, g.id, u.user_id, a.id, a.type,
a.max_points, ai.max_points,
COALESCE(a.max_bonus_points, 0), ai.max_bonus_points,
ai.open
INTO
course_id, group_id, user_id, assessment_id, assessment_type,
assessment_max_points, old_assessment_instance_max_points,
assessment_max_bonus_points, old_assessment_instance_max_bonus_points,
assessment_instance_open
FROM
assessment_instances AS ai
Expand Down Expand Up @@ -102,24 +107,24 @@ BEGIN
-- did we add any instance questions above?
updated := updated OR (cardinality(new_instance_question_ids) > 0);

SELECT sum(zmp.max_points)
INTO zones_total_max_points
FROM assessment_instances_points(assessment_instance_id) AS zmp;

-- determine the correct max_points
new_assessment_instance_max_points := assessment_max_points;
IF new_assessment_instance_max_points IS NULL THEN
SELECT
sum(zmp.max_points)
INTO
new_assessment_instance_max_points
FROM
assessment_instances_points(assessment_instance_id) AS zmp;
END IF;
new_assessment_instance_max_points := COALESCE(assessment_max_points, GREATEST(zones_total_max_points - assessment_max_bonus_points, 0));

-- ensure that max_bonus_points is not greater than the number of available points
assessment_max_bonus_points := GREATEST(LEAST(assessment_max_bonus_points, zones_total_max_points - new_assessment_instance_max_points), 0);

-- update max_points if necessary and log it
IF new_assessment_instance_max_points IS DISTINCT FROM old_assessment_instance_max_points THEN
IF new_assessment_instance_max_points IS DISTINCT FROM old_assessment_instance_max_points OR assessment_max_bonus_points IS DISTINCT FROM old_assessment_instance_max_bonus_points THEN
updated := TRUE;

UPDATE assessment_instances AS ai
SET
max_points = new_assessment_instance_max_points,
max_bonus_points = assessment_max_bonus_points,
modified_at = now()
WHERE
ai.id = assessment_instance_id;
Expand All @@ -132,8 +137,8 @@ BEGIN
VALUES
(authn_user_id, course_id, user_id, group_id,
'assessment_instances', 'max_points', assessment_instance_id,
'update', jsonb_build_object('max_points', old_assessment_instance_max_points),
jsonb_build_object('max_points', new_assessment_instance_max_points));
'update', jsonb_build_object('max_points', old_assessment_instance_max_points, 'max_bonus_points', old_assessment_instance_max_bonus_points),
jsonb_build_object('max_points', new_assessment_instance_max_points, 'max_bonus_points', assessment_max_bonus_points));
END IF;
END;
$$ LANGUAGE plpgsql VOLATILE;
1 change: 1 addition & 0 deletions sprocs/sync_assessments.sql
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ BEGIN
multiple_instance = (valid_assessment.data->>'multiple_instance')::boolean,
shuffle_questions = (valid_assessment.data->>'shuffle_questions')::boolean,
max_points = (valid_assessment.data->>'max_points')::double precision,
max_bonus_points = (valid_assessment.data->>'max_bonus_points')::double precision,
auto_close = (valid_assessment.data->>'auto_close')::boolean,
text = valid_assessment.data->>'text',
assessment_set_id = aggregates.assessment_set_id,
Expand Down
1 change: 1 addition & 0 deletions sync/course-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ const FILE_UUID_REGEX = /"uuid":\s*"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4
* @property {boolean} shuffleQuestions
* @property {AssessmentAllowAccess[]} allowAccess
* @property {string} text
* @property {number} maxBonusPoints
* @property {number} maxPoints
* @property {boolean} autoClose
* @property {Zone[]} zones
Expand Down
1 change: 1 addition & 0 deletions sync/fromDisk/assessments.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function getParamsForAssessment(assessmentInfoFile, questionIds) {
require_honor_code: requireHonorCode,
auto_close: !!_.get(assessment, 'autoClose', true),
max_points: assessment.maxPoints,
max_bonus_points: assessment.maxBonusPoints,
set_name: assessment.set,
text: assessment.text,
constant_question_value: !!_.get(assessment, 'constantQuestionValue', false),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"uuid": "97ba7431-ebb2-463c-990c-94e1fdabdd75",
"type": "Homework",
"title": "Bonus points",
"set": "Homework",
"number": "7",
"maxPoints": 10,
"maxBonusPoints": 2,
"allowAccess": [
{
"mode": "Public",
"credit": 100
}
],
"zones": [
{
"questions": [
{"id": "partialCredit1", "points": 8},
{"id": "partialCredit2", "points": 8}
]
}
]
}
1 change: 1 addition & 0 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require('./testExam');
require('./testRealTimeGradingDisabled');
require('./testShowClosedAssessmentScore');
require('./testGradeRate');
require('./testBonusPoints');
require('./testAccess');
require('./testAccessAsStudent');
require('./testCourseElementExtension');
Expand Down
Loading

0 comments on commit a941343

Please sign in to comment.