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

Move main Scorer and Validator to PerseusScore #2165

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

Conversation

handeyeco
Copy link
Contributor

@handeyeco handeyeco commented Jan 28, 2025

Summary:

You don't have to look at this alone. I would be happy to give you a tour.

I'm sorry, I didn't see a way to break this up into pieces that could be moved independent of one another.

It's honestly not a lot of changes, just changes that needed to happen while moving things.

The main idea:

  • The original goal of the PR was to move scorePerseusItem
  • That required moving logic for upgrading widgets
  • Upgrading required the alignment moves
  • Since PerseusCore has upgrade logic and PerseusScore has scoring logic, they both needed registries
    • We're discussing merging the two packages to reduce the maintenance burden
  • Since this stuff has to run on the server and the server can't be connected to React, I removed logic that was stuck onto WidgetExports

That's pretty much it: moved scoring logic, moved stuff scoring logic depended on, updated imports, and cleaned up WidgetExports.

Issue: LEMS-2737

Test plan:

  • Widgets can be upgraded
  • Widgets can be validated
  • Widgets can be scored

Virtually no user-facing changes should occur.

@handeyeco handeyeco self-assigned this Jan 28, 2025
@handeyeco handeyeco changed the title [LEMS-2737/move-scoring-logic] WIP: lint/tsc happy, tests sad Move main Scorer and Validator to PerseusScore Jan 28, 2025
Copy link
Contributor

github-actions bot commented Jan 28, 2025

Size Change: -765 B (-0.05%)

Total Size: 1.48 MB

Filename Size Change
packages/perseus-core/dist/es/index.js 46 kB +2.48 kB (+5.71%) 🔍
packages/perseus-editor/dist/es/index.js 688 kB +14 B (0%)
packages/perseus-score/dist/es/index.js 115 kB +2.29 kB (+2.03%)
packages/perseus/dist/es/index.js 376 kB -5.55 kB (-1.45%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 39 kB
packages/keypad-context/dist/es/index.js 760 B
packages/kmath/dist/es/index.js 86.8 kB
packages/math-input/dist/es/index.js 77.6 kB
packages/math-input/dist/es/strings.js 1.79 kB
packages/perseus-linter/dist/es/index.js 22.2 kB
packages/perseus/dist/es/strings.js 5.82 kB
packages/pure-markdown/dist/es/index.js 3.66 kB
packages/simple-markdown/dist/es/index.js 12.5 kB

compressed-size-action

@@ -324,7 +324,6 @@ function GradableRenderer(props: QuestionRendererProps) {
const score = scorePerseusItem(
question,
rendererRef.current.getUserInputMap(),
mockStrings,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now all validators and scorers should be sending error codes instead of error messages, so we don't need strings anymore.

name: "graded-group-set",
defaultWidgetOptions,
};

export default GradedGroupSetWidgetLogic;
export default gradedGroupSetWidgetLogic;
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 were just mistakes from previous PRs.

@@ -26,6 +26,8 @@ const defaultWidgetOptions: ImageDefaultWidgetOptions = {
const imageWidgetLogic: WidgetLogic = {
name: "image",
defaultWidgetOptions,
supportedAlignments: ["block", "full-width"],
defaultAlignment: "block",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alignment is used during the upgrade process, that's why I moved all of these.


// Unversioned widgets (pre-July 2014) are all implicitly 0.0
const initialVersion = oldWidgetInfo.version || {major: 0, minor: 0};
const latestVersion = getCurrentVersion(type);
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 a change, basically just adding a helper here.

// some point, and we would like to not break when that happens).
let newEditorOptions = _.clone(oldWidgetInfo.options) || {};

const upgradePropsMap = getWidgetOptionsUpgrades(type);
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 a change, basically just adding a helper here.

return widgets[name]?.scorer ?? null;
};

registerWidget(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Jeremy and I are talking about merging PerseusScore and PerseusCore so there's only one registry for the logic (along with a registry for the editors and a registry for the UI). In the meantime...

@@ -569,9 +538,6 @@ export type WidgetExports<
* This key defaults to `{major: 0, minor: 0}` if not provided.
*/
version?: Version;
supportedAlignments?: ReadonlyArray<Alignment>;
defaultAlignment?: Alignment;
getDefaultAlignment?: () => Alignment;
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 got rid of getDefaultAlignment. It didn't seem like any of the components used it?

Copy link
Collaborator

Choose a reason for hiding this comment

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

You're right! There's a shared getDefaultAlignment that's used, but nothing calls this function on individual widgets.

it("should always return 0 points", async () => {
const result = ExplanationWidgetExports?.scorer?.();

expect(result).toHaveBeenAnsweredCorrectly({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

idk, this seemed to be more of a test for noopScorer which has its own tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed. Doesn't actually seem like a useful test.

Comment on lines -252 to +253
"countChoices": undefined,
"deselectEnabled": undefined,
"countChoices": false,
"deselectEnabled": false,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, this is worth pointing out. I have no idea why this changed and I can't figure it out. To me it seemed that these should have been false though? So IDK.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this test end up upgrading the widget options ... and in doing so now seat some defaults that it didn't before? I'll pull the branch and look a bit more..

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep. That's it. The Renderer upgrades the widget options and now that you've pulled the defaultOptions from the editors, they're actually being applied during the upgrade. Previously, we didn't register the widget editors at all for these tests, so minor version upgrades never changed anything! I think your change is correct (or at least restores the original intention of this upgrade code).

image

registerWidget("deprecated-standin", () => scoreNoop(1) as any);
registerWidget("measurer", () => scoreNoop(1) as any);

registerWidget("definition", scoreNoop as any);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to get rid of scoreNoop but I didn't feel like now was the right moment.

Copy link
Contributor

github-actions bot commented Jan 28, 2025

npm Snapshot: Published

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

Example:

yarn add @khanacademy/perseus@PR2165

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

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

@handeyeco handeyeco marked this pull request as ready for review January 28, 2025 22:46
@handeyeco handeyeco requested review from a team January 28, 2025 22:46
PerseusWidgetsMap,
} from "@khanacademy/perseus-core";

describe("flattenScores", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most of this is copied as-is except for the removal of strings and the refactor I pointed out.

@@ -0,0 +1,186 @@
// TODO: combine scorePerseusItem with scoreWidgetsFunctional

import {
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 logic is mostly copied as-is except the removal of strings


describe("scoreExpression", () => {
it("should be correctly answerable if validation passes", function () {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed tests related to validation from scoring tests

@@ -16,118 +16,3 @@ describe("isCorrect", () => {
expect(isCorrect(score)).toBe(false);
});
});

describe("flattenScores", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Was moved

);
}

export function flattenScores(widgetScoreMap: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All moved

* Supported alignments are given as an array of strings in the exports of
* a widget's module.
*/
export const getSupportedAlignments = (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All moved

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.

Yahoo! I made it through. Some assorted comments for you, but this looks good to me.

export type * from "./widgets/logic-export.types";

export * as WidgetLogic from "./widgets/widget-registry";
Copy link
Collaborator

Choose a reason for hiding this comment

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

The naming difference here feels like perhaps this should be exported as WidgetRegistry. :)

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 renamed the export and file to CoreWidgetRegistry/core-widget-registry

Comment on lines 83 to 93
throw new PerseusError(
"No upgrade found for widget. Cannot render.",
Errors.Internal,
{
metadata: {
type,
fromMajorVersion: nextVersion - 1,
toMajorVersion: nextVersion,
},
},
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's reasonable to throw here. Perhaps we could include information from the widget options that might help us trace which content is causing this? Maybe just add JSON.serialize(oldWidgetInfo) to metadata here?

Comment on lines 123 to 132
return _.extend({}, oldWidgetInfo, {
// maintain other info, like type
// After upgrading we guarantee that the version is up-to-date
version: latestVersion,
// Default graded to true (so null/undefined becomes true):
graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true,
alignment: alignment,
static: widgetStatic,
options: newEditorOptions,
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unrelated to the changes you're making, but this feels like a really easy improvement:

Suggested change
return _.extend({}, oldWidgetInfo, {
// maintain other info, like type
// After upgrading we guarantee that the version is up-to-date
version: latestVersion,
// Default graded to true (so null/undefined becomes true):
graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true,
alignment: alignment,
static: widgetStatic,
options: newEditorOptions,
});
return {
...oldWidgetInfo,
// maintain other info, like type
// After upgrading we guarantee that the version is up-to-date
version: latestVersion,
// Default graded to true (so null/undefined becomes true):
graded: oldWidgetInfo.graded != null ? oldWidgetInfo.graded : true,
alignment: alignment,
static: widgetStatic,
options: newEditorOptions,
};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, but I ran into a hairy type issue so I had to cast as any

Comment on lines +143 to +146
// TODO: why does widget have no type?
// We don't want to derive type from widget ID
// see: LEMS-1845
newValues.type = widgetId.split(" ")[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd be tempted to start throwing in the face of a missing widget type. Right now, we limp along and gloss over this data this is quite broken (and I suspect oooooold).

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 can make this change, but I wasn't sure if you were asking me to or not. I personally would like to avoid making this PR more risky than it already is, but I don't feel strongly about it either way. I agree that it seems low risk to throw here.

// select box. If the widget only supports one alignment, the
// alignment value will likely just end up as "default".
if (alignment == null || alignment === "default") {
alignment = getSupportedAlignments(type)[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Cool! The array access makes me a bit paranoid. Could we add a safety mechanism in getSupportedAlignments to throw if there are no supported alignments? Fully aware that this is extremely paranoid and you might just want to land this as is. :)

Comment on lines -252 to +253
"countChoices": undefined,
"deselectEnabled": undefined,
"countChoices": false,
"deselectEnabled": false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this test end up upgrading the widget options ... and in doing so now seat some defaults that it didn't before? I'll pull the branch and look a bit more..

Comment on lines -252 to +253
"countChoices": undefined,
"deselectEnabled": undefined,
"countChoices": false,
"deselectEnabled": false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep. That's it. The Renderer upgrades the widget options and now that you've pulled the defaultOptions from the editors, they're actually being applied during the upgrade. Previously, we didn't register the widget editors at all for these tests, so minor version upgrades never changed anything! I think your change is correct (or at least restores the original intention of this upgrade code).

image

it("should always return 0 points", async () => {
const result = ExplanationWidgetExports?.scorer?.();

expect(result).toHaveBeenAnsweredCorrectly({
Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed. Doesn't actually seem like a useful test.

@@ -569,9 +538,6 @@ export type WidgetExports<
* This key defaults to `{major: 0, minor: 0}` if not provided.
*/
version?: Version;
supportedAlignments?: ReadonlyArray<Alignment>;
defaultAlignment?: Alignment;
getDefaultAlignment?: () => Alignment;
Copy link
Collaborator

Choose a reason for hiding this comment

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

You're right! There's a shared getDefaultAlignment that's used, but nothing calls this function on individual widgets.

value: string;
};

export type PerseusMockWidgetUserInput = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm tempted to pull it into its own package (it could be a private package that's used anywhere in our Perseus repo, but it'd be structured as a real package to help guide us). I don't think this is a rush but might be something to try to see how it affects other organization (and move us to better support external widgets).

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.

2 participants