Repository: Khan/perseus
Pull request #2368: Create helper for DINER emptiness
Author: login: handeyeco
Labels: name: olc-4.0.628d3
Created: 2025-04-09T16:56:49Z | Merged: 2025-04-09T18:42:15Z

## Summary:
**The Problem**
1. `emptyWidgetsFunctional` considers a NumericInput as non-empty with a value of `""`. That's because in some contexts, `""` _is_ a correct answer (when `coefficient` is `true` and one of the answers is `1`). But we can't check that the answer is 1 on the FE, because the FE can't have answers. While we need to preserve this behavior for KA Classic, AX doesn't care about the coefficient edge-case; they just want `""` to be considered empty.
2. Our original workaround was to switch from `emptyWidgetsFunctional` to `scorePerseusItem`. However, `scorePerseusItem` does _too much_ validation for AX, which is the root of LEMS-3000.

**Proposed Solution**
For now, this is a quick and dirty hack: just copy what `emptyWidgetsFunctional` is doing and we can adjust it to meet AX's needs.

Issue: LEMS-3000


Diff:
diff --git a/.changeset/dirty-socks-crash.md b/.changeset/dirty-socks-crash.md
new file mode 100644
index 00000000000..60db4552cfc
--- /dev/null
+++ b/.changeset/dirty-socks-crash.md
@@ -0,0 +1,6 @@
+---
+"@khanacademy/perseus-score": minor
+"@khanacademy/perseus-core": patch
+---
+
+Add AX-specific empty widget function to address LEMS-3000
diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts
index 8d8b6e6d518..40963c42994 100644
--- a/packages/perseus-core/src/data-schema.ts
+++ b/packages/perseus-core/src/data-schema.ts
@@ -1253,7 +1253,7 @@ export type PerseusNumericInputAnswer = {
     // A range of error +/- the value
     // 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"
+    // Unsimplified answers are Ungraded, Accepted, or Wrong.
     simplify: PerseusNumericInputSimplify;
 };
 
diff --git a/packages/perseus-score/src/had-empty-diner-widgets.test.ts b/packages/perseus-score/src/had-empty-diner-widgets.test.ts
new file mode 100644
index 00000000000..035bbbc300e
--- /dev/null
+++ b/packages/perseus-score/src/had-empty-diner-widgets.test.ts
@@ -0,0 +1,288 @@
+import hasEmptyDINERWidgets from "./has-empty-diner-widgets";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem, PerseusRenderer} from "@khanacademy/perseus-core";
+
+function generatePerseusItem(question: PerseusRenderer): PerseusItem {
+    return {
+        question,
+        answerArea: {
+            calculator: false,
+            chi2Table: false,
+            periodicTable: false,
+            tTable: false,
+            zTable: false,
+            financialCalculatorMonthlyPayment: false,
+            financialCalculatorTotalAmount: false,
+            financialCalculatorTimeToPayOff: false,
+            periodicTableWithKey: false,
+        },
+        itemDataVersion: {
+            major: 0,
+            minor: 1,
+        },
+        hints: [],
+        answer: null,
+    };
+}
+
+function generateBasicDropdownQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ dropdown 1]]",
+        widgets: {
+            "dropdown 1": {
+                type: "dropdown",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                    placeholder: "Pick one",
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicNumericInputQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ numeric-input 1]]",
+        widgets: {
+            "numeric-input 1": {
+                type: "numeric-input",
+                options: {
+                    answers: [
+                        {
+                            value: 42,
+                            message: "This is correct",
+                            status: "correct",
+                            strict: false,
+                            simplify: "optional",
+                            maxError: null,
+                        },
+                    ],
+                    size: "normal",
+                    coefficient: false,
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicExpressionQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ expression 1]]",
+        widgets: {
+            "expression 1": {
+                type: "expression",
+                options: {
+                    answerForms: [
+                        {
+                            value: "42",
+                            form: false,
+                            simplify: false,
+                            considered: "correct",
+                        },
+                    ],
+                    buttonSets: [],
+                    functions: [],
+                    times: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicRadioQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ radio 1]]",
+        widgets: {
+            "radio 1": {
+                type: "radio",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+describe(`hasEmptyDINERWidgets`, () => {
+    it("passes an answered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 1,
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("fails an unanswered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 0,
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(true);
+    });
+
+    it("passes any interactive graph", () => {
+        // No point in making a whole bunch of data
+        // IG doesn't do client-side validation
+        const question: any = {
+            question: {
+                content: "[[☃ interactive-graph 1]]",
+                widgets: {
+                    "interactive-graph 1": {},
+                },
+            },
+        };
+
+        const userInput: any = {};
+
+        expect(hasEmptyDINERWidgets(question, userInput)).toBe(false);
+    });
+
+    it("passes an answered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "42",
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("fails an unanswered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "",
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(true);
+    });
+
+    it("passes an unanswered numeric-input (coefficient edge case)", () => {
+        const question = generateBasicNumericInputQuestion();
+        question.widgets["numeric-input 1"].options.coefficient = true;
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                // When coefficient is true and there's an answer of `1`
+                // an empty string is a valid answer
+                currentValue: "",
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("passes an answered numeric-input (coefficient edge case)", () => {
+        const question = generateBasicNumericInputQuestion();
+        question.widgets["numeric-input 1"].options.coefficient = true;
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "42",
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("passes an answered expression", () => {
+        const question = generateBasicExpressionQuestion();
+
+        const userInput: UserInputMap = {
+            "expression 1": "42",
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("fails an unanswered expression", () => {
+        const question = generateBasicExpressionQuestion();
+
+        const userInput: UserInputMap = {
+            "expression 1": "",
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(true);
+    });
+
+    it("passes an answered radio", () => {
+        const question = generateBasicRadioQuestion();
+
+        const userInput: UserInputMap = {
+            "radio 1": {
+                choicesSelected: [true, false],
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(false);
+    });
+
+    it("fails an unanswered radio", () => {
+        const question = generateBasicRadioQuestion();
+
+        const userInput: UserInputMap = {
+            "radio 1": {
+                choicesSelected: [false, false],
+            },
+        };
+
+        expect(
+            hasEmptyDINERWidgets(generatePerseusItem(question), userInput),
+        ).toBe(true);
+    });
+});
diff --git a/packages/perseus-score/src/has-empty-diner-widgets.ts b/packages/perseus-score/src/has-empty-diner-widgets.ts
new file mode 100644
index 00000000000..778396728fc
--- /dev/null
+++ b/packages/perseus-score/src/has-empty-diner-widgets.ts
@@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function hasEmptyDINERWidgets(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
+                if (input.value === 0) {
+                    return true;
+                }
+                break;
+            }
+            case "interactive-graph": {
+                // IG doesn't do client-side validation
+                break;
+            }
+            case "numeric-input": {
+                // this is possibly a source of bugs,
+                // really it should be:
+                // empty input && not coefficient && answer is not 1
+                // but we don't have answers here
+                // (see: `scoreNumericInput`)
+                if (!input.currentValue && !widget.options.coefficient) {
+                    return true;
+                }
+                break;
+            }
+            case "expression": {
+                // see `validateExpression`
+                if (!input) {
+                    return true;
+                }
+                break;
+            }
+            case "radio": {
+                // see `validateRadio`
+                if (!input.choicesSelected.includes(true)) {
+                    return true;
+                }
+                break;
+            }
+        }
+    }
+
+    return false;
+}
+
+export default hasEmptyDINERWidgets;
diff --git a/packages/perseus-score/src/index.ts b/packages/perseus-score/src/index.ts
index b8bc0e86026..f8af224011e 100644
--- a/packages/perseus-score/src/index.ts
+++ b/packages/perseus-score/src/index.ts
@@ -39,6 +39,7 @@ export {
 
 export {scorePerseusItem, scoreWidgetsFunctional, flattenScores} from "./score";
 export {emptyWidgetsFunctional} from "./validate";
+export {default as hasEmptyDINERWidgets} from "./has-empty-diner-widgets";
 
 export type {
     PerseusMockWidgetRubric,
diff --git a/packages/perseus-score/src/widgets/radio/validate-radio.ts b/packages/perseus-score/src/widgets/radio/validate-radio.ts
index 9d27aef9f7c..9ee49c97f34 100644
--- a/packages/perseus-score/src/widgets/radio/validate-radio.ts
+++ b/packages/perseus-score/src/widgets/radio/validate-radio.ts
@@ -12,11 +12,7 @@ import type {
  * @see `scoreRadio` for the additional validation logic and the scoring logic.
  */
 function validateRadio(userInput: PerseusRadioUserInput): ValidationResult {
-    const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
-        return sum + (selected ? 1 : 0);
-    }, 0);
-
-    if (numSelected === 0) {
+    if (!userInput.choicesSelected.includes(true)) {
         return {
             type: "invalid",
             message: null,


Reviews:
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:06:10Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:07:58Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:08:38Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:09:12Z
user: login: adinardi
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-09T17:32:48Z
user: login: jeremywiebe
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-09T17:33:07Z
user: login: adinardi
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-09T17:33:58Z
user: login: adinardi
body: seems like it'll probably work! I had a question about graphs.
state: APPROVED
author_association: NONE
submitted_at: 2025-04-09T17:37:22Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:46:03Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:46:52Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:27:43Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:28:09Z

Review comments:
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
path: packages/perseus-score/src/empty-diner-check.ts
user: login: handeyeco
body: I thought about using the validate functions directly, but I figured we've had some issues juggling AX and KA Classic so I figured I'd just keep them separate for now.
created_at: 2025-04-09T17:06:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1253,7 +1253,7 @@ export type PerseusNumericInputAnswer = {
     // A range of error +/- the value
     // 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"
+    // Unsimplified answers are Ungraded, Accepted, or Wrong.
path: packages/perseus-core/src/data-schema.ts
user: login: handeyeco
body: The comment was wrong and there's now a real TS type for this.
created_at: 2025-04-09T17:07:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
+                if (input.value === 0) {
+                    return true;
+                }
+                break;
+            }
+            case "interactive-graph": {
+                // IG doesn't do client-side validation
+                break;
+            }
+            case "numeric-input": {
+                // this is possibly a source of bugs,
+                // really it should be:
+                // empty input && not coefficient && answer is not 1
+                // but we don't have answers here
+                // (see: `scoreNumericInput`)
+                if (!input.currentValue && !widget.options.coefficient) {
path: packages/perseus-score/src/empty-diner-check.ts
user: login: handeyeco
body: I could also check for "answer === 1" here, but we can't have that in the future so I don't see the point of adding it now.
created_at: 2025-04-09T17:08:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,11 +12,7 @@ import type {
  * @see `scoreRadio` for the additional validation logic and the scoring logic.
  */
 function validateRadio(userInput: PerseusRadioUserInput): ValidationResult {
-    const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
-        return sum + (selected ? 1 : 0);
-    }, 0);
-
-    if (numSelected === 0) {
+    if (!userInput.choicesSelected.includes(true)) {
path: packages/perseus-score/src/widgets/radio/validate-radio.ts
user: login: handeyeco
body: I think this is the same...
created_at: 2025-04-09T17:09:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1253,7 +1253,7 @@ export type PerseusNumericInputAnswer = {
     // A range of error +/- the value
     // 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"
+    // Unsimplified answers are Ungraded, Accepted, or Wrong.
path: packages/perseus-core/src/data-schema.ts
user: login: jeremywiebe
body: Nice.  
created_at: 2025-04-09T17:17:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,288 @@
+import emptyDinerCheck from "./empty-diner-check";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem, PerseusRenderer} from "@khanacademy/perseus-core";
+
+function generatePerseusItem(question: PerseusRenderer): PerseusItem {
path: packages/perseus-score/src/empty-diner-check.test.ts
user: login: jeremywiebe
body: Non-blocking, but would you mind making a TODO to come back and merge this with the one in Perseus?  Or maybe it's not that big a deal to have two similar test helpers in different packages. 

https://github.com/Khan/perseus/blob/c8e383b469426182a1392ca6ad2cde21b61e2f40/packages/perseus/src/util/test-utils.ts#L61
created_at: 2025-04-09T17:21:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,288 @@
+import emptyDinerCheck from "./empty-diner-check";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem, PerseusRenderer} from "@khanacademy/perseus-core";
+
+function generatePerseusItem(question: PerseusRenderer): PerseusItem {
+    return {
+        question,
+        answerArea: {
+            calculator: false,
+            chi2Table: false,
+            periodicTable: false,
+            tTable: false,
+            zTable: false,
+            financialCalculatorMonthlyPayment: false,
+            financialCalculatorTotalAmount: false,
+            financialCalculatorTimeToPayOff: false,
+            periodicTableWithKey: false,
+        },
+        itemDataVersion: {
+            major: 0,
+            minor: 1,
+        },
+        hints: [],
+        answer: null,
+    };
+}
+
+function generateBasicDropdownQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ dropdown 1]]",
+        widgets: {
+            "dropdown 1": {
+                type: "dropdown",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                    placeholder: "Pick one",
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicNumericInputQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ numeric-input 1]]",
+        widgets: {
+            "numeric-input 1": {
+                type: "numeric-input",
+                options: {
+                    answers: [
+                        {
+                            value: 42,
+                            message: "This is correct",
+                            status: "correct",
+                            strict: false,
+                            simplify: "optional",
+                            maxError: null,
+                        },
+                    ],
+                    size: "normal",
+                    coefficient: false,
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicExpressionQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ expression 1]]",
+        widgets: {
+            "expression 1": {
+                type: "expression",
+                options: {
+                    answerForms: [
+                        {
+                            value: "42",
+                            form: false,
+                            simplify: false,
+                            considered: "correct",
+                        },
+                    ],
+                    buttonSets: [],
+                    functions: [],
+                    times: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicRadioQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ radio 1]]",
+        widgets: {
+            "radio 1": {
+                type: "radio",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+describe(`emptyDinerCheck`, () => {
+    it("passes an answered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 1,
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            false,
+        );
+    });
+
+    it("fails an unanswered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 0,
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            true,
+        );
+    });
+
+    it("passes any interactive graph", () => {
+        // No point in making a whole bunch of data
+        // IG doesn't do client-side validation
+        const question: any = {
+            question: {
+                content: "[[☃ interactive-graph 1]]",
+                widgets: {
+                    "interactive-graph 1": {},
+                },
+            },
+        };
+
+        const userInput: any = {};
+
+        expect(emptyDinerCheck(question, userInput)).toBe(false);
+    });
+
+    it("passes an answered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "42",
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            false,
+        );
+    });
+
+    it("fails an unanswered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "",
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            true,
+        );
+    });
+
+    it("passes an unanswered numeric-input (coefficient edge case)", () => {
path: packages/perseus-score/src/empty-diner-check.test.ts
user: login: jeremywiebe
body: This is protective, but we don't expect these to show up for projects that exclusively use the DINER widgets, right? 
created_at: 2025-04-09T17:25:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
path: packages/perseus-score/src/empty-diner-check.ts
user: login: jeremywiebe
body: This function name doesn't immediately reveal the meaning of the answer. When going through the unit tests, I had to think about what `true` and `false` meant. What do you think of `hasEmptyDinerWidgets()`?  Or `hasEmptyWidgetsDiner()`? 
created_at: 2025-04-09T17:28:21Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
path: packages/perseus-score/src/empty-diner-check.ts
user: login: jeremywiebe
body: I think that's fair.  I'd love for this to be a short-term solution that we can remove once we settle on what to do with the `coefficient` flag and empty `numeric-input` widgets .
created_at: 2025-04-09T17:29:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
+                if (input.value === 0) {
+                    return true;
+                }
+                break;
+            }
+            case "interactive-graph": {
+                // IG doesn't do client-side validation
path: packages/perseus-score/src/empty-diner-check.ts
user: login: adinardi
body: So there's no sort of "must interact" with it or anything else? It's just always good to go?
created_at: 2025-04-09T17:32:48Z
author_association: NONE
diff_hunk: @@ -12,11 +12,7 @@ import type {
  * @see `scoreRadio` for the additional validation logic and the scoring logic.
  */
 function validateRadio(userInput: PerseusRadioUserInput): ValidationResult {
-    const numSelected = userInput.choicesSelected.reduce((sum, selected) => {
-        return sum + (selected ? 1 : 0);
-    }, 0);
-
-    if (numSelected === 0) {
+    if (!userInput.choicesSelected.includes(true)) {
path: packages/perseus-score/src/widgets/radio/validate-radio.ts
user: login: jeremywiebe
body: Lol, I believe that's right.  We just want to know if _any_ choices are selected (ie `true`). 
created_at: 2025-04-09T17:33:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
path: packages/perseus-score/src/empty-diner-check.ts
user: login: adinardi
body: Do we need to document anything in SSS reqs or some tickets or anything? I'd just want to make sure we continue to have the AX cases handled in an SSS world.
created_at: 2025-04-09T17:33:58Z
author_association: NONE
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
path: packages/perseus-score/src/empty-diner-check.ts
user: login: handeyeco
body: I think the important thing is to have AX unit tests for all cases that are considered important.
created_at: 2025-04-09T17:46:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+import {getWidgetIdsFromContent} from "@khanacademy/perseus-core";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem} from "@khanacademy/perseus-core";
+
+/**
+ * Check the emptiness of DINER widgets (for the AX team):
+ * - Dropdown
+ * - InteractiveGraph
+ * - NumericInput
+ * - Expression
+ * - Radio
+ *
+ * @param {PerseusItem} itemData
+ * @param {UserInputMap} userInputMap
+ * @returns {boolean} true if there's an empty widget, otherwise false
+ */
+function emptyDinerCheck(
+    itemData: PerseusItem,
+    userInputMap: UserInputMap,
+): boolean {
+    const usedWidgetIds = getWidgetIdsFromContent(itemData.question.content);
+    const widgets = itemData.question.widgets;
+
+    for (const widgetId of usedWidgetIds) {
+        const widget = widgets[widgetId];
+        const input = userInputMap[widgetId];
+
+        switch (widget.type) {
+            case "dropdown": {
+                // see `validateDropdown`
+                if (input.value === 0) {
+                    return true;
+                }
+                break;
+            }
+            case "interactive-graph": {
+                // IG doesn't do client-side validation
path: packages/perseus-score/src/empty-diner-check.ts
user: login: handeyeco
body: Not that I can see. There's no validator, the scoring function doesn't seem to look for anything like that, and user input doesn't have any kind of flag to indicate that it's been interacted with.
created_at: 2025-04-09T17:46:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,288 @@
+import emptyDinerCheck from "./empty-diner-check";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem, PerseusRenderer} from "@khanacademy/perseus-core";
+
+function generatePerseusItem(question: PerseusRenderer): PerseusItem {
path: packages/perseus-score/src/empty-diner-check.test.ts
user: login: handeyeco
body: I'll file a ticket. I just couldn't import from `perseus` into `perseus-score`. I think we just need a central place to help construct objects.

EDIT: https://khanacademy.atlassian.net/browse/LEMS-3020
created_at: 2025-04-09T18:27:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,288 @@
+import emptyDinerCheck from "./empty-diner-check";
+
+import type {UserInputMap} from "./validation.types";
+import type {PerseusItem, PerseusRenderer} from "@khanacademy/perseus-core";
+
+function generatePerseusItem(question: PerseusRenderer): PerseusItem {
+    return {
+        question,
+        answerArea: {
+            calculator: false,
+            chi2Table: false,
+            periodicTable: false,
+            tTable: false,
+            zTable: false,
+            financialCalculatorMonthlyPayment: false,
+            financialCalculatorTotalAmount: false,
+            financialCalculatorTimeToPayOff: false,
+            periodicTableWithKey: false,
+        },
+        itemDataVersion: {
+            major: 0,
+            minor: 1,
+        },
+        hints: [],
+        answer: null,
+    };
+}
+
+function generateBasicDropdownQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ dropdown 1]]",
+        widgets: {
+            "dropdown 1": {
+                type: "dropdown",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                    placeholder: "Pick one",
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicNumericInputQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ numeric-input 1]]",
+        widgets: {
+            "numeric-input 1": {
+                type: "numeric-input",
+                options: {
+                    answers: [
+                        {
+                            value: 42,
+                            message: "This is correct",
+                            status: "correct",
+                            strict: false,
+                            simplify: "optional",
+                            maxError: null,
+                        },
+                    ],
+                    size: "normal",
+                    coefficient: false,
+                    static: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicExpressionQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ expression 1]]",
+        widgets: {
+            "expression 1": {
+                type: "expression",
+                options: {
+                    answerForms: [
+                        {
+                            value: "42",
+                            form: false,
+                            simplify: false,
+                            considered: "correct",
+                        },
+                    ],
+                    buttonSets: [],
+                    functions: [],
+                    times: false,
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+function generateBasicRadioQuestion(): PerseusRenderer {
+    return {
+        content: "[[☃ radio 1]]",
+        widgets: {
+            "radio 1": {
+                type: "radio",
+                options: {
+                    choices: [
+                        {
+                            content: "Correct",
+                            correct: true,
+                        },
+                        {
+                            content: "Incorrect",
+                            correct: false,
+                        },
+                    ],
+                },
+            },
+        },
+        images: {},
+    };
+}
+
+describe(`emptyDinerCheck`, () => {
+    it("passes an answered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 1,
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            false,
+        );
+    });
+
+    it("fails an unanswered dropdown", () => {
+        const question = generateBasicDropdownQuestion();
+
+        const userInput: UserInputMap = {
+            "dropdown 1": {
+                value: 0,
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            true,
+        );
+    });
+
+    it("passes any interactive graph", () => {
+        // No point in making a whole bunch of data
+        // IG doesn't do client-side validation
+        const question: any = {
+            question: {
+                content: "[[☃ interactive-graph 1]]",
+                widgets: {
+                    "interactive-graph 1": {},
+                },
+            },
+        };
+
+        const userInput: any = {};
+
+        expect(emptyDinerCheck(question, userInput)).toBe(false);
+    });
+
+    it("passes an answered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "42",
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            false,
+        );
+    });
+
+    it("fails an unanswered numeric-input", () => {
+        const question = generateBasicNumericInputQuestion();
+
+        const userInput: UserInputMap = {
+            "numeric-input 1": {
+                currentValue: "",
+            },
+        };
+
+        expect(emptyDinerCheck(generatePerseusItem(question), userInput)).toBe(
+            true,
+        );
+    });
+
+    it("passes an unanswered numeric-input (coefficient edge case)", () => {
path: packages/perseus-score/src/empty-diner-check.test.ts
user: login: handeyeco
body: Correct. Just adding just in case.
created_at: 2025-04-09T18:28:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-09T16:58:08Z
body: **Size Change:** +139 B (+0.03%) 

**Total Size:** 425 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `packages/perseus-score/dist/es/index.js` | 9.04 kB | +139 B (+1.56%) |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `packages/kas/dist/es/index.js` | 20.7 kB |
| `packages/keypad-context/dist/es/index.js` | 1 kB |
| `packages/kmath/dist/es/index.js` | 5.98 kB |
| `packages/math-input/dist/es/index.js` | 57.1 kB |
| `packages/math-input/dist/es/strings.js` | 1.61 kB |
| `packages/perseus-core/dist/es/index.js` | 18.3 kB |
| `packages/perseus-editor/dist/es/index.js` | 90.6 kB |
| `packages/perseus-linter/dist/es/index.js` | 7.05 kB |
| `packages/perseus-utils/dist/es/index.js` | 403 B |
| `packages/perseus/dist/es/index.js` | 199 kB |
| `packages/perseus/dist/es/strings.js` | 6.13 kB |
| `packages/pure-markdown/dist/es/index.js` | 1.22 kB |
| `packages/simple-markdown/dist/es/index.js` | 6.71 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-09T16:58:58Z
body: # npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (777c9556) and published it to npm. You
can install it using the tag `PR2368`.

Example:
```sh
pnpm add @khanacademy/perseus@PR2368
```

If you are working in Khan Academy's webapp, you can run:
```sh
./dev/tools/bump_perseus_version.sh -t PR2368
```
author_association: CONTRIBUTOR
user: login: timmcca-be
created_at: 2025-04-09T18:12:02Z
body: ![Screenshot 2025-04-09 at 2 10 46 PM](https://github.com/user-attachments/assets/b1f6ac47-098b-49c0-9f71-e8894092a49d)

I figured it out eventually ([DINER refers to the class of widgets we use in assessments](https://khanacademy.atlassian.net/wiki/spaces/LC/pages/3276734623/Project+Plan+for+SSS#Rough-overview-of-upcoming-AX-work)), but thought this was funny
author_association: NONE