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

[LEMS-2849] Answerless Radio #2233

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open

Conversation

handeyeco
Copy link
Contributor

@handeyeco handeyeco commented Feb 12, 2025

Summary:

So this focuses on numCorrect which originally derived from the answers but now is its own widget option. I'm sure there's an argument that this also shouldn't be sent to the FE, but it's conditionally used to render help text: Select 2 answers. I don't know, I don't think it's that big of a deal to have it but I also made it optional so we can remove it from questions that don't need it: needsNumCorrect = options.multipleSelect === true && options.countChoices === true.

The important thing is that we can render, answer, and score a Radio widget that's been stripped of answers. As far as I can tell, this supports all behavior pre-scoring.

Issue: LEMS-2849

Test plan:

Radio should continue to be renderable, answerable, and scorable.

@handeyeco handeyeco self-assigned this Feb 12, 2025
Copy link
Contributor

github-actions bot commented Feb 12, 2025

Size Change: +710 B (+0.08%)

Total Size: 871 kB

Filename Size Change
packages/perseus-core/dist/es/index.js 30.5 kB +700 B (+2.35%)
packages/perseus/dist/es/index.js 366 kB +10 B (0%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 39.5 kB
packages/keypad-context/dist/es/index.js 760 B
packages/kmath/dist/es/index.js 11.1 kB
packages/math-input/dist/es/index.js 78.2 kB
packages/math-input/dist/es/strings.js 1.79 kB
packages/perseus-editor/dist/es/index.js 276 kB
packages/perseus-linter/dist/es/index.js 22.8 kB
packages/perseus-score/dist/es/index.js 20.6 kB
packages/perseus/dist/es/strings.js 6.57 kB
packages/pure-markdown/dist/es/index.js 4.14 kB
packages/simple-markdown/dist/es/index.js 13.1 kB

compressed-size-action

@handeyeco handeyeco changed the title [LEMS-2849/radio-answerless] add broken test for answerful requirement Answerless Radio Feb 12, 2025
@handeyeco handeyeco changed the title Answerless Radio [LEMS-2849] Answerless Radio Feb 13, 2025
}

const version0 = optional(object({major: constant(0), minor: number}));
const parseRadioWidgetV0: Parser<RadioWidget> = parseWidgetWithVersion(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

So Radio version 0 and 1 already existed, but for some reason there wasn't an existing migration for Radio. The parser got upset with me when I tried to add a migration from 1 to 2 without having one for 0 to 1.

Copy link
Member

Choose a reason for hiding this comment

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

I didn't write a migration for v0 -> v1 because it's fairly trivial and I don't think hasNoneOfTheAbove is even used anymore.

    "1": (v0props: any): PerseusRadioWidgetOptions => {
        const {noneOfTheAbove, ...rest} = v0props;

        if (noneOfTheAbove) {
            throw new Error(
                "radio widget v0 no longer supports auto noneOfTheAbove",
            );
        }

        return {
            ...rest,
            hasNoneOfTheAbove: false,
        };
    },

Comment on lines -109 to -108
const numCorrect: number = _.reduce(
widgetOptions.choices,
function (memo, choice) {
return choice.correct ? memo + 1 : memo;
},
0,
);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The main idea is to pull this logic out of transform and call it during the upgrade process. That way the widget can do all the pre-scoring things it needs to without answers (ie. render and be answerable).

// assert that functionality previous based on answers still works
expect(screen.getByRole("group", {name: "Choose 2 answers:"}));

const userInput = renderer.getUserInputMap();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is important because we can do everything we need to do pre-scoring with the answerless data now as far as I can tell.

@handeyeco handeyeco requested review from jeremywiebe, benchristel, Myranae and a team February 13, 2025 16:54
Copy link
Member

@benchristel benchristel left a comment

Choose a reason for hiding this comment

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

I left a few minor comments and suggestions inline, but nothing blocking. LGTM!

Comment on lines 145 to 151
return {
...widget,
version: {major: 1, minor: 0},
options: {
...options,
},
};
Copy link
Member

Choose a reason for hiding this comment

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

Do we even need to copy the options here?

Suggested change
return {
...widget,
version: {major: 1, minor: 0},
options: {
...options,
},
};
return {
...widget,
version: {major: 1, minor: 0},
};

Alternatively, maybe we should remove noneOfTheAbove and set hasNoneOfTheAbove to false, like in the original upgrade function?

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

it("is interactive with stripped widget options", async () => {
Copy link
Member

Choose a reason for hiding this comment

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

We're using a few different terms for this concept:

  • public options
  • stripped options
  • answerless options

I'd prefer to standardize on one, for the sake of future maintainers.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The functions are all named getPublicWidgetOptions... so it feels like we've gone with "public". I'm a bit averse to "stripped" after starting with it. It feels aggressive. "Answerless" isn't always 100% correct because there are situations where we remove data that isn't the answer, but could reveal info about the answer. 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I personally think "public" is the most incorrect. Answerless/answerful are both public, just at different times. But I can change it for this PR.

Copy link
Member

Choose a reason for hiding this comment

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

+1 for keeping things consistent for now. A temporarily unclear name is better than temporary inconsistency. Find-replace is easy, but gets harder when naming isn't consistent.

curr.correct ? acc + 1 : acc,
0,
)
);
Copy link
Member

Choose a reason for hiding this comment

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

I see this algorithm was copied from the component code. I think there's a simpler way. I suspect the JIT can optimize this:

choices.filter((c) => c.correct).length

to avoid creating the intermediate array. Even if it doesn't... 🤷

constant("radio"),
object({
numCorrect: optional(number),
Copy link
Member

Choose a reason for hiding this comment

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

This makes me slightly nervous. numCorrect only needs to be optional because we'll be running this parser on two different inputs:

  • data loaded from datastore, where numCorrect is usually not present (and maybe never needs to be present, since it can be derived?)
  • data sent to the the client, where numCorrect must be present if the data is answerless.

I worry that if the parsers accumulate a lot of irregularities of this kind, we'll be back in the position of not really understanding our data schema.

But I also think that it would be a pain in the butt to maintain separate parsers for answerless and answerful data.

One idea I'm toying with is letting the ParseContext keep track of whether we're parsing answerless or answerful data. Then we could write widget options parsers like this:

object({
  numCorrect: ifAnswerful(optional(number)).ifAnswerless(number),
})

But maybe that's overkill? I guess one thing I don't understand is whether the plan is to persist numCorrect in the answerful data or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For NumericInput v2 numCorrect will:

  • always be in the data store
  • always be in the full widget options
  • will conditionally be in the "public" widget options

I added a commit since your comment that changes things. Maybe this is resolved now?

Copy link
Contributor

github-actions bot commented Feb 13, 2025

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (70304ba) and published it to npm. You
can install it using the tag PR2233.

Example:

pnpm add @khanacademy/perseus@PR2233

If you are working in Khan Academy's webapp, you can run:

./dev/tools/bump_perseus_version.sh -t PR2233

Copy link
Collaborator

@jeremywiebe jeremywiebe left a comment

Choose a reason for hiding this comment

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

Nice. I like the approach of removing countChoices in get getPublicWidgetOptions.

Comment on lines +1346 to +1348
// How many of the choices are correct, which is conditionally used to tell
// learners ahead of time how many options they'll need.
numCorrect?: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Does updating this file require a stakeholder update? Apologies for pointing this out each time, but the memories of what Ned went through and Jeremy always talking about protecting this file are strong 😅

numCorrect: PerseusRadioWidgetOptions["numCorrect"],
) {
return multipleSelect && countChoices && numCorrect;
}
Copy link
Member

@benchristel benchristel Feb 21, 2025

Choose a reason for hiding this comment

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

It's kind of unfortunate that this code is coupled to logic in the widget. We're predicting when the widget is going to need numCorrect, which feels brittle. EDIT: I see we're calling this function in the widget! In that case, I think it should be named something like revealNumCorrect or showNumCorrectInInstructions.

It seems like we could leverage the type system's ability to model the different possibilities here. Something like:

type PerseusRadioWidgetOptions = {
  responseFormat: SingleSelect | MultiSelect,
  // ...
}

type SingleSelect =  {type: "single-select"};

type MultiSelect = {
  type: "multi-select",
  /**
   * If present, the number of choices the learner must select for
   * their response to be accepted. Responses with a different number
   * of selected choices are marked invalid rather than incorrect. The
   * number of correct choices is displayed in the widget instructions.
   *
   * If null, the learner can submit a response with any number of 
   * selected options. Responses with the wrong number of options 
   * selected are marked incorrect.
   */
  numCorrect: number | null,
}

This would require a major version bump of the widget options format, so perhaps it's not something we want to take on right now. It might be worth migrating to this in the future. EDIT: ugh, sorry for the confusion. I was reading the diff of one commit thinking it was the whole PR. I see now that you are bumping the major version. In that case, I think changing to this more-structured format would be nice if we can make it work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants