diff --git a/.changeset/tricky-hairs-grin.md b/.changeset/tricky-hairs-grin.md new file mode 100644 index 0000000000..914b8030ff --- /dev/null +++ b/.changeset/tricky-hairs-grin.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-core": minor +--- + +Implement a widget export function to filter out rubric data from widget options for the Matcher widget diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index dc70e8eab3..4a5113061e 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -138,3 +138,8 @@ export {default as getTablePublicWidgetOptions} from "./widgets/table/table-util export {default as getIFramePublicWidgetOptions} from "./widgets/iframe/iframe-util"; export {default as getMatrixPublicWidgetOptions} from "./widgets/matrix/matrix-util"; export {default as getPlotterPublicWidgetOptions} from "./widgets/plotter/plotter-util"; +export { + default as getMatcherPublicWidgetOptions, + shuffleMatcher, +} from "./widgets/matcher/matcher-util"; +export {shuffle, seededRNG, random} from "./utils/random-util"; diff --git a/packages/perseus-core/src/utils/random-util.ts b/packages/perseus-core/src/utils/random-util.ts new file mode 100644 index 0000000000..8cc527b1e9 --- /dev/null +++ b/packages/perseus-core/src/utils/random-util.ts @@ -0,0 +1,69 @@ +/* Note(tamara): Brought over from the perseus package packages/perseus/src/util.ts file. + May be useful to bring other perseus package utilities here. Contains utility functions + and types used across multiple widgets for randomization and shuffling. */ + +import _ from "underscore"; + +type RNG = () => number; + +export const seededRNG: (seed: number) => RNG = function (seed: number): RNG { + let randomSeed = seed; + + return function () { + // Robert Jenkins' 32 bit integer hash function. + let seed = randomSeed; + seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff; + seed = (seed ^ 0xc761c23c ^ (seed >>> 19)) & 0xffffffff; + seed = (seed + 0x165667b1 + (seed << 5)) & 0xffffffff; + seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff; + seed = (seed + 0xfd7046c5 + (seed << 3)) & 0xffffffff; + seed = (seed ^ 0xb55a4f09 ^ (seed >>> 16)) & 0xffffffff; + return (randomSeed = seed & 0xfffffff) / 0x10000000; + }; +}; + +// Shuffle an array using a given random seed or function. +// If `ensurePermuted` is true, the input and output are guaranteed to be +// distinct permutations. +export function shuffle( + array: ReadonlyArray, + randomSeed: number | RNG, + ensurePermuted = false, +): ReadonlyArray { + // Always return a copy of the input array + const shuffled = _.clone(array); + + // Handle edge cases (input array is empty or uniform) + if ( + !shuffled.length || + _.all(shuffled, function (value) { + return _.isEqual(value, shuffled[0]); + }) + ) { + return shuffled; + } + + let random; + if (typeof randomSeed === "function") { + random = randomSeed; + } else { + random = seededRNG(randomSeed); + } + + do { + // Fischer-Yates shuffle + for (let top = shuffled.length; top > 0; top--) { + const newEnd = Math.floor(random() * top); + const temp = shuffled[newEnd]; + + // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. + shuffled[newEnd] = shuffled[top - 1]; + // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. + shuffled[top - 1] = temp; + } + } while (ensurePermuted && _.isEqual(array, shuffled)); + + return shuffled; +} + +export const random: RNG = seededRNG(new Date().getTime() & 0xffffffff); diff --git a/packages/perseus-core/src/widgets/matcher/matcher-util.test.ts b/packages/perseus-core/src/widgets/matcher/matcher-util.test.ts new file mode 100644 index 0000000000..865a59663f --- /dev/null +++ b/packages/perseus-core/src/widgets/matcher/matcher-util.test.ts @@ -0,0 +1,51 @@ +import getMatcherPublicWidgetOptions from "./matcher-util"; + +import type {PerseusMatcherWidgetOptions} from "../../data-schema"; + +describe("getMatcherPublicWidgetOptions", () => { + it("should return a shuffled right array when order doesn't matter to remove the correct order information", () => { + // Arrange + const options: PerseusMatcherWidgetOptions = { + labels: ["**Claims**", "**Evidence**"], + padding: true, + orderMatters: false, + left: ["Fuel", "Plate", "Hydrogen", "Average", "Billion"], + right: ["Stars", "Earth", "Life", "Rapid", "Milky Way"], + }; + + // Act + const publicWidgetOptions = getMatcherPublicWidgetOptions(options); + + // Assert + expect(publicWidgetOptions.left).toEqual(options.left); + expect(new Set(publicWidgetOptions.right)).toEqual( + new Set(options.right), + ); + expect(publicWidgetOptions.right).not.toEqual(options.right); + }); + + it("should return shuffled left and right arrays when order matters to remove the correct order information", () => { + // Arrange + const options: PerseusMatcherWidgetOptions = { + labels: ["**Claims**", "**Evidence**"], + padding: true, + orderMatters: true, + left: ["Fuel", "Plate", "Hydrogen", "Average", "Billion"], + right: ["Stars", "Earth", "Life", "Rapid", "Milky Way"], + }; + + // Act + const publicWidgetOptions = getMatcherPublicWidgetOptions(options); + + // Assert + expect(new Set(publicWidgetOptions.left)).toEqual( + new Set(options.left), + ); + expect(publicWidgetOptions.left).not.toEqual(options.left); + + expect(new Set(publicWidgetOptions.right)).toEqual( + new Set(options.right), + ); + expect(publicWidgetOptions.right).not.toEqual(options.right); + }); +}); diff --git a/packages/perseus-core/src/widgets/matcher/matcher-util.ts b/packages/perseus-core/src/widgets/matcher/matcher-util.ts new file mode 100644 index 0000000000..44e8b417db --- /dev/null +++ b/packages/perseus-core/src/widgets/matcher/matcher-util.ts @@ -0,0 +1,86 @@ +import {seededRNG, shuffle} from "@khanacademy/perseus-core"; + +import type {PerseusMatcherWidgetOptions} from "@khanacademy/perseus-core"; + +// TODO(LEMS-2841): Should be able to remove once getPublicWidgetOptions is hooked up +type MatcherInfo = { + left: ReadonlyArray; + right: ReadonlyArray; + orderMatters: boolean; + problemNum: number | null | undefined; +}; + +type MatcherShuffleInfo = { + left: ReadonlyArray; + right: ReadonlyArray; + orderMatters: boolean; +}; + +// TODO(LEMS-2841): Should be able to remove once getPublicWidgetOptions is hooked up +export const shuffleMatcher = ( + props: MatcherInfo, +): {left: ReadonlyArray; right: ReadonlyArray} => { + // Use the same random() function to shuffle both columns sequentially + const rng = seededRNG(props.problemNum as number); + + let left; + if (!props.orderMatters) { + // If the order doesn't matter, don't shuffle the left column + left = props.left; + } else { + left = shuffle(props.left, rng, /* ensurePermuted */ true); + } + + const right = shuffle(props.right, rng, /* ensurePermuted */ true); + + return {left, right}; +}; + +// TODO(LEMS-2841): Can shorten to shuffleMatcher after above function removed +function shuffleMatcherWithRandom(data: MatcherShuffleInfo): { + left: ReadonlyArray; + right: ReadonlyArray; +} { + // Use the same random() function to shuffle both columns sequentially + let left; + if (!data.orderMatters) { + // If the order doesn't matter, don't shuffle the left column + left = data.left; + } else { + left = shuffle(data.left, Math.random, /* ensurePermuted */ true); + } + + const right = shuffle(data.right, Math.random, /* ensurePermuted */ true); + + return {left, right}; +} + +/** + * For details on the individual options, see the + * PerseusMatcherWidgetOptions type + */ +type MatcherPublicWidgetOptions = { + labels: PerseusMatcherWidgetOptions["labels"]; + left: PerseusMatcherWidgetOptions["left"]; + right: PerseusMatcherWidgetOptions["right"]; + orderMatters: PerseusMatcherWidgetOptions["orderMatters"]; + padding: PerseusMatcherWidgetOptions["padding"]; +}; + +/** + * Given a PerseusMatcherWidgetOptions object, return a new object with only + * the public options that should be exposed to the client. + */ +function getMatcherPublicWidgetOptions( + options: PerseusMatcherWidgetOptions, +): MatcherPublicWidgetOptions { + const {left, right} = shuffleMatcherWithRandom(options); + + return { + ...options, + left: left, + right: right, + }; +} + +export default getMatcherPublicWidgetOptions; diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 24d2f38d8f..09a234d962 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -36,6 +36,7 @@ import type { getIFramePublicWidgetOptions, getMatrixPublicWidgetOptions, getPlotterPublicWidgetOptions, + getMatcherPublicWidgetOptions, } from "@khanacademy/perseus-core"; import type {LinterContextProps} from "@khanacademy/perseus-linter"; import type { @@ -496,6 +497,7 @@ export type PublicWidgetOptionsFunction = | typeof getPlotterPublicWidgetOptions | typeof getIFramePublicWidgetOptions | typeof getRadioPublicWidgetOptions + | typeof getMatcherPublicWidgetOptions | typeof getNumericInputPublicWidgetOptions | typeof getDropdownPublicWidgetOptions | typeof getCategorizerPublicWidgetOptions diff --git a/packages/perseus/src/util.ts b/packages/perseus/src/util.ts index 9fcd365604..7dd0b37e14 100644 --- a/packages/perseus/src/util.ts +++ b/packages/perseus/src/util.ts @@ -17,8 +17,6 @@ type WordAndPosition = { pos: WordPosition; }; -type RNG = () => number; - export type ParsedValue = { value: number; exact: boolean; @@ -92,66 +90,6 @@ const rTypeFromWidgetId = /^([a-z-]+) ([0-9]+)$/; const rWidgetParts = new RegExp(rWidgetRule.source + "$"); const snowman = "\u2603"; -const seededRNG: (seed: number) => RNG = function (seed: number): RNG { - let randomSeed = seed; - - return function () { - // Robert Jenkins' 32 bit integer hash function. - let seed = randomSeed; - seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff; - seed = (seed ^ 0xc761c23c ^ (seed >>> 19)) & 0xffffffff; - seed = (seed + 0x165667b1 + (seed << 5)) & 0xffffffff; - seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff; - seed = (seed + 0xfd7046c5 + (seed << 3)) & 0xffffffff; - seed = (seed ^ 0xb55a4f09 ^ (seed >>> 16)) & 0xffffffff; - return (randomSeed = seed & 0xfffffff) / 0x10000000; - }; -}; - -// Shuffle an array using a given random seed or function. -// If `ensurePermuted` is true, the input and ouput are guaranteed to be -// distinct permutations. -function shuffle( - array: ReadonlyArray, - randomSeed: number | RNG, - ensurePermuted = false, -): ReadonlyArray { - // Always return a copy of the input array - const shuffled = _.clone(array); - - // Handle edge cases (input array is empty or uniform) - if ( - !shuffled.length || - _.all(shuffled, function (value) { - return _.isEqual(value, shuffled[0]); - }) - ) { - return shuffled; - } - - let random; - if (typeof randomSeed === "function") { - random = randomSeed; - } else { - random = seededRNG(randomSeed); - } - - do { - // Fischer-Yates shuffle - for (let top = shuffled.length; top > 0; top--) { - const newEnd = Math.floor(random() * top); - const temp = shuffled[newEnd]; - - // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. - shuffled[newEnd] = shuffled[top - 1]; - // @ts-expect-error - TS2542 - Index signature in type 'readonly T[]' only permits reading. - shuffled[top - 1] = temp; - } - } while (ensurePermuted && _.isEqual(array, shuffled)); - - return shuffled; -} - /** * TODO(somewhatabstract, FEI-3463): * Drop this custom split thing. @@ -648,8 +586,6 @@ const textarea = { const unescapeMathMode: (label: string) => string = (label) => label.startsWith("$") && label.endsWith("$") ? label.slice(1, -1) : label; -const random: RNG = seededRNG(new Date().getTime() & 0xffffffff); - const Util = { inputPathsEqual, nestedMap, @@ -657,8 +593,6 @@ const Util = { rTypeFromWidgetId, rWidgetParts, snowman, - seededRNG, - shuffle, split, firstNumericalParse, stringArrayOfSize, @@ -687,7 +621,6 @@ const Util = { getDataUrl: GraphieUtil.getDataUrl, textarea, unescapeMathMode, - random, } as const; export default Util; diff --git a/packages/perseus/src/widgets/categorizer/categorizer.tsx b/packages/perseus/src/widgets/categorizer/categorizer.tsx index 982e8dd01d..62c7c63b2a 100644 --- a/packages/perseus/src/widgets/categorizer/categorizer.tsx +++ b/packages/perseus/src/widgets/categorizer/categorizer.tsx @@ -2,6 +2,7 @@ import { type PerseusCategorizerWidgetOptions, getCategorizerPublicWidgetOptions, + shuffle, } from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import {StyleSheet, css} from "aphrodite"; @@ -16,7 +17,6 @@ import * as Changeable from "../../mixins/changeable"; import Renderer from "../../renderer"; import mediaQueries from "../../styles/media-queries"; import sharedStyles from "../../styles/shared"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/categorizer/categorizer-ai-utils"; import type {Widget, WidgetExports, WidgetProps} from "../../types"; @@ -90,10 +90,13 @@ export class Categorizer // In this context, isMobile is used to differentiate mobile from // desktop. const isMobile = this.props.apiOptions.isMobile; - let indexedItems = this.props.items.map((item, n) => [item, n]); + let indexedItems: ReadonlyArray> = + this.props.items.map((item, n) => [item, n]); if (this.props.randomizeItems) { - // @ts-expect-error - TS4104 - The type 'readonly (string | number)[][]' is 'readonly' and cannot be assigned to the mutable type '(string | number)[][]'. | TS2345 - Argument of type 'number | null | undefined' is not assignable to parameter of type 'number | RNG'. - indexedItems = Util.shuffle(indexedItems, this.props.problemNum); + indexedItems = shuffle( + indexedItems, + this.props.problemNum as number, + ); } const table = ( @@ -128,7 +131,6 @@ export class Categorizer implements Widget { ); } - // Use the same random() function to shuffle both columns sequentially - const rng = seededRNG(this.props.problemNum as number); - - let left; - if (!this.props.orderMatters) { - // If the order doesn't matter, don't shuffle the left column - left = this.props.left; - } else { - left = shuffle(this.props.left, rng, /* ensurePermuted */ true); - } - - const right = shuffle(this.props.right, rng, /* ensurePermuted */ true); + /* TODO(LEMS-2841): + Once the getMatcherPublicWidgetOptions function gets connected to the + widget, we'll need to update this to the line below to only shuffle + on the server. + const {left, right} = this.props; + */ + const {left, right} = shuffleMatcher(this.props); const showLabels = _.any(this.props.labels); const constraints = { @@ -286,4 +283,5 @@ export default { displayName: "Matcher (two column)", widget: Matcher, isLintable: true, + getPublicWidgetOptions: getMatcherPublicWidgetOptions, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/radio/radio.ts b/packages/perseus/src/widgets/radio/radio.ts index d8ef591d55..0f610b7dfc 100644 --- a/packages/perseus/src/widgets/radio/radio.ts +++ b/packages/perseus/src/widgets/radio/radio.ts @@ -2,19 +2,17 @@ import { radioLogic, type PerseusRadioWidgetOptions, getRadioPublicWidgetOptions, + random, + shuffle, } from "@khanacademy/perseus-core"; import _ from "underscore"; -import Util from "../../util"; - import Radio from "./radio-component"; import type {RenderProps, RadioChoiceWithMetadata} from "./radio-component"; import type {PerseusStrings} from "../../strings"; import type {WidgetExports} from "../../types"; -const {shuffle, random} = Util; - // Transforms the choices for display. const _choiceTransform = ( widgetOptions: PerseusRadioWidgetOptions, diff --git a/packages/perseus/src/widgets/sorter/sorter.tsx b/packages/perseus/src/widgets/sorter/sorter.tsx index 763926b29d..c1e74fdaa0 100644 --- a/packages/perseus/src/widgets/sorter/sorter.tsx +++ b/packages/perseus/src/widgets/sorter/sorter.tsx @@ -2,11 +2,11 @@ import { type PerseusSorterWidgetOptions, getSorterPublicWidgetOptions, } from "@khanacademy/perseus-core"; +import {shuffle} from "@khanacademy/perseus-core"; import {linterContextDefault} from "@khanacademy/perseus-linter"; import * as React from "react"; import Sortable from "../../components/sortable"; -import Util from "../../util"; import {getPromptJSON as _getPromptJSON} from "../../widget-ai-utils/sorter/sorter-ai-utils"; import type {SortableOption} from "../../components/sortable"; @@ -17,8 +17,6 @@ import type { PerseusSorterUserInput, } from "@khanacademy/perseus-score"; -const {shuffle} = Util; - type RenderProps = PerseusSorterWidgetOptions; type Props = WidgetProps;