Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Numeric Input: Treat unrecognized simplify values as "required" #2308

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/perseus-core/src/data-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1206,15 +1206,19 @@ export type MathFormat =
| "pi";

export type PerseusNumericInputAnswerForm = {
simplify: PerseusNumericInputSimplify | null | undefined;
simplify: PerseusNumericInputSimplify;
name: MathFormat;
};

export type PerseusNumericInputSimplify =
| "required"
| "correct"
| "enforced"
| "optional";
/**
* Determines how unsimplified fractions are scored.
*
* - "required" means unsimplified fractions are considered invalid input, and
* the learner can try again.
* - "enforced" means unsimplified fractions are marked incorrect.
* - "optional" means unsimplified fractions are accepted.
*/
export type PerseusNumericInputSimplify = "required" | "enforced" | "optional";

export type PerseusNumericInputWidgetOptions = {
// A list of all the possible correct and incorrect answers
Expand Down Expand Up @@ -1249,7 +1253,7 @@ export type PerseusNumericInputAnswer = {
// NOTE: perseus_data.go says this is non-nullable even though we handle null values.
maxError: number | null | undefined;
// Unsimplified answers are Ungraded, Accepted, or Wrong. Options: "required", "correct", or "enforced"
simplify: PerseusNumericInputSimplify | null | undefined;
simplify: PerseusNumericInputSimplify;
};

export type PerseusNumberLineWidgetOptions = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {defaulted} from "../general-purpose-parsers/defaulted";

import {parseWidget} from "./widget";

import type {NumericInputWidget} from "../../data-schema";
import type {
NumericInputWidget,
PerseusNumericInputSimplify,
} from "../../data-schema";
import type {Parser} from "../parser-types";

const parseMathFormat = enumeration(
Expand All @@ -29,12 +32,41 @@ const parseMathFormat = enumeration(
"pi",
);

const parseSimplify = enumeration(
"required",
"correct",
"enforced",
"optional",
);
const parseSimplify = pipeParsers(
union(constant(null))
.or(constant(undefined))
.or(boolean)
.or(constant("required"))
.or(constant("correct"))
.or(constant("enforced"))
.or(constant("optional"))
.or(constant("accepted")).parser,
).then(convert(deprecatedSimplifyValuesToRequired)).parser;

function deprecatedSimplifyValuesToRequired(
simplify:
| "required"
| "correct"
| "enforced"
| "optional"
| "accepted"
| null
| undefined
| boolean,
): PerseusNumericInputSimplify {
switch (simplify) {
case "enforced":
case "required":
case "optional":
return simplify;
// NOTE(benchristel): "accepted", "correct", true, false, undefined, and
// null are all treated the same as "required" during scoring, so we
// convert them to "required" here to preserve behavior. See the tests
// in score-numeric-input.test.ts
default:
return "required";
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to add tests for this.


export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
constant("numeric-input"),
Expand All @@ -53,20 +85,7 @@ export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
// TODO(benchristel): simplify should never be a boolean, but we
// have some content where it is anyway. If we ever backfill
// the data, we should simplify `simplify`.
simplify: optional(
nullable(
union(parseSimplify).or(
pipeParsers(boolean).then(
convert((value) => {
if (typeof value === "boolean") {
return value ? "required" : "optional";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SonicScrewdriver I changed the behavior here to match what the code was doing in production before this parser existed. It seems like the scoring logic treats both true and false as "required", so I removed the conditional that converts false into "optional". Please let me know if I'm missing something here!

}
return value;
}),
).parser,
).parser,
),
),
simplify: parseSimplify,
}),
),
labelText: optional(string),
Expand All @@ -78,16 +97,7 @@ export const parseNumericInputWidget: Parser<NumericInputWidget> = parseWidget(
array(
object({
name: parseMathFormat,
simplify: optional(
nullable(
enumeration(
"required",
"correct",
"enforced",
"optional",
),
),
),
simplify: parseSimplify,
}),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,120 @@ describe("scoreNumericInput", () => {

expect(score).toHaveBeenAnsweredIncorrectly();
});

// Tests for the "simplify" widget option:
//
// - simplify: "enforced" means unsimplified fractions are marked incorrect.
// - simplify: "required" means unsimplified fractions are returned as
// invalid, and the learner can try again.
// - simplify: "optional" means unsimplified fractions are accepted as
// correct.
//
// NOTE(benchristel): "accepted", "correct", booleans, undefined, and null
// are treated the same as "required". They are not valid values for
// `simplify` according to our types, but they appear in production data.
// The tests for these values characterize the current behavior as of
// 2025-03-18. Note that "accepted", "correct", booleans, undefined, and
// null are treated the same as "required".
describe.each`
simplify | unsimplifiedFractionScore
${"enforced"} | ${"incorrect"}
${"optional"} | ${"correct"}
${"required"} | ${"invalid"}
${"accepted"} | ${"invalid"}
${"correct"} | ${"invalid"}
${undefined} | ${"invalid"}
${null} | ${"invalid"}
${false} | ${"invalid"}
${true} | ${"invalid"}
`("with simplify: $simplify", ({simplify, unsimplifiedFractionScore}) => {
it(`marks unsimplified fractions ${unsimplifiedFractionScore}`, () => {
const answerMatchers = {
incorrect: expect.objectContaining({
type: "points",
earned: 0,
}),

correct: expect.objectContaining({
type: "points",
earned: 1,
}),

invalid: expect.objectContaining({
type: "invalid",
}),
};

const expected = answerMatchers[unsimplifiedFractionScore];

// Arrange
const rubric: PerseusNumericInputRubric = {
coefficient: false,
answers: [
{
value: 0.5,
status: "correct",
maxError: 0,
simplify,
strict: false,
message: "",
},
],
};

// Act
const score = scoreNumericInput({currentValue: "2/4"}, rubric);

// Assert
expect(score).toEqual(expected);
});

it("marks a simplified fraction correct", () => {
// Arrange
const rubric: PerseusNumericInputRubric = {
coefficient: false,
answers: [
{
value: 0.5,
status: "correct",
maxError: 0,
simplify,
strict: false,
message: "",
},
],
};

// Act
const score = scoreNumericInput({currentValue: "1/2"}, rubric);

// Assert
expect(score).toHaveBeenAnsweredCorrectly();
});

it("marks an incorrect fraction incorrect", () => {
// Arrange
const rubric: PerseusNumericInputRubric = {
coefficient: false,
answers: [
{
value: 0.5,
status: "correct",
maxError: 0,
simplify,
strict: false,
message: "",
},
],
};

// Act
const score = scoreNumericInput({currentValue: "2/3"}, rubric);

// Assert
expect(score).toHaveBeenAnsweredIncorrectly();
});
});
});

describe("maybeParsePercentInput utility function", () => {
Expand Down
Loading