New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

@bdelab/jscat

Package Overview
Dependencies
Maintainers
0
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@bdelab/jscat - npm Package Compare versions

Comparing version 4.0.0 to 5.0.0

lib/cat.d.ts

94

lib/index.d.ts

@@ -1,90 +0,4 @@

import { Stimulus, Zeta } from './type';
export declare const abilityPrior: number[][];
export interface CatInput {
method?: string;
itemSelect?: string;
nStartItems?: number;
startSelect?: string;
theta?: number;
minTheta?: number;
maxTheta?: number;
prior?: number[][];
randomSeed?: string | null;
}
export declare class Cat {
method: string;
itemSelect: string;
minTheta: number;
maxTheta: number;
prior: number[][];
private readonly _zetas;
private readonly _resps;
private _nItems;
private _theta;
private _seMeasurement;
nStartItems: number;
startSelect: string;
private readonly _rng;
/**
* Create a Cat object. This expects an single object parameter with the following keys
* @param {{method: string, itemSelect: string, nStartItems: number, startSelect:string, theta: number, minTheta: number, maxTheta: number, prior: number[][]}=} destructuredParam
* method: ability estimator, e.g. MLE or EAP, default = 'MLE'
* itemSelect: the method of item selection, e.g. "MFI", "random", "closest", default method = 'MFI'
* nStartItems: first n trials to keep non-adaptive selection
* startSelect: rule to select first n trials
* theta: initial theta estimate
* minTheta: lower bound of theta
* maxTheta: higher bound of theta
* prior: the prior distribution
* randomSeed: set a random seed to trace the simulation
*/
constructor({ method, itemSelect, nStartItems, startSelect, theta, minTheta, maxTheta, prior, randomSeed, }?: CatInput);
get theta(): number;
get seMeasurement(): number;
get nItems(): number;
get resps(): (0 | 1)[];
get zetas(): Zeta[];
private static validateMethod;
private static validateItemSelect;
private static validateStartSelect;
/**
* use previous response patterns and item params to calculate the estimate ability based on a defined method
* @param zeta - last item param
* @param answer - last response pattern
* @param method
*/
updateAbilityEstimate(zeta: Zeta | Zeta[], answer: (0 | 1) | (0 | 1)[], method?: string): void;
private estimateAbilityEAP;
private estimateAbilityMLE;
private negLikelihood;
private likelihood;
/**
* calculate the standard error of ability estimation
*/
private calculateSE;
/**
* find the next available item from an input array of stimuli based on a selection method
*
* remainingStimuli is sorted by fisher information to reduce the computation complexity for future item selection
* @param stimuli - an array of stimulus
* @param itemSelect - the item selection method
* @param deepCopy - default deepCopy = true
* @returns {nextStimulus: Stimulus,
remainingStimuli: Array<Stimulus>}
*/
findNextItem(stimuli: Stimulus[], itemSelect?: string, deepCopy?: boolean): {
nextStimulus: Stimulus;
remainingStimuli: Stimulus[];
};
private selectorMFI;
private selectorMiddle;
private selectorClosest;
private selectorRandom;
/**
* return a random integer between min and max
* @param min - The minimum of the random number range (include)
* @param max - The maximum of the random number range (include)
* @returns {number} - random integer within the range
*/
private randomInteger;
}
export { Cat, CatInput } from './cat';
export { Clowder, ClowderInput } from './clowder';
export { prepareClowderCorpus } from './corpus';
export { EarlyStopping, StopAfterNItems, StopOnSEMeasurementPlateau, StopIfSEMeasurementBelowThreshold, } from './stopping';
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Cat = exports.abilityPrior = void 0;
const optimization_js_1 = require("optimization-js");
const lodash_1 = require("lodash");
const utils_1 = require("./utils");
const seedrandom_1 = __importDefault(require("seedrandom"));
exports.abilityPrior = (0, utils_1.normal)();
class Cat {
/**
* Create a Cat object. This expects an single object parameter with the following keys
* @param {{method: string, itemSelect: string, nStartItems: number, startSelect:string, theta: number, minTheta: number, maxTheta: number, prior: number[][]}=} destructuredParam
* method: ability estimator, e.g. MLE or EAP, default = 'MLE'
* itemSelect: the method of item selection, e.g. "MFI", "random", "closest", default method = 'MFI'
* nStartItems: first n trials to keep non-adaptive selection
* startSelect: rule to select first n trials
* theta: initial theta estimate
* minTheta: lower bound of theta
* maxTheta: higher bound of theta
* prior: the prior distribution
* randomSeed: set a random seed to trace the simulation
*/
constructor({ method = 'MLE', itemSelect = 'MFI', nStartItems = 0, startSelect = 'middle', theta = 0, minTheta = -6, maxTheta = 6, prior = exports.abilityPrior, randomSeed = null, } = {}) {
this.method = Cat.validateMethod(method);
this.itemSelect = Cat.validateItemSelect(itemSelect);
this.startSelect = Cat.validateStartSelect(startSelect);
this.minTheta = minTheta;
this.maxTheta = maxTheta;
this.prior = prior;
this._zetas = [];
this._resps = [];
this._theta = theta;
this._nItems = 0;
this._seMeasurement = Number.MAX_VALUE;
this.nStartItems = nStartItems;
this._rng = randomSeed === null ? (0, seedrandom_1.default)() : (0, seedrandom_1.default)(randomSeed);
}
get theta() {
return this._theta;
}
get seMeasurement() {
return this._seMeasurement;
}
get nItems() {
return this._resps.length;
}
get resps() {
return this._resps;
}
get zetas() {
return this._zetas;
}
static validateMethod(method) {
const lowerMethod = method.toLowerCase();
const validMethods = ['mle', 'eap']; // TO DO: add staircase
if (!validMethods.includes(lowerMethod)) {
throw new Error('The abilityEstimator you provided is not in the list of valid methods');
}
return lowerMethod;
}
static validateItemSelect(itemSelect) {
const lowerItemSelect = itemSelect.toLowerCase();
const validItemSelect = ['mfi', 'random', 'closest'];
if (!validItemSelect.includes(lowerItemSelect)) {
throw new Error('The itemSelector you provided is not in the list of valid methods');
}
return lowerItemSelect;
}
static validateStartSelect(startSelect) {
const lowerStartSelect = startSelect.toLowerCase();
const validStartSelect = ['random', 'middle']; // TO DO: add staircase
if (!validStartSelect.includes(lowerStartSelect)) {
throw new Error('The startSelect you provided is not in the list of valid methods');
}
return lowerStartSelect;
}
/**
* use previous response patterns and item params to calculate the estimate ability based on a defined method
* @param zeta - last item param
* @param answer - last response pattern
* @param method
*/
updateAbilityEstimate(zeta, answer, method = this.method) {
method = Cat.validateMethod(method);
zeta = Array.isArray(zeta) ? zeta : [zeta];
answer = Array.isArray(answer) ? answer : [answer];
if (zeta.length !== answer.length) {
throw new Error('Unmatched length between answers and item params');
}
this._zetas.push(...zeta);
this._resps.push(...answer);
if (method === 'eap') {
this._theta = this.estimateAbilityEAP();
}
else if (method === 'mle') {
this._theta = this.estimateAbilityMLE();
}
this.calculateSE();
}
estimateAbilityEAP() {
let num = 0;
let nf = 0;
this.prior.forEach(([theta, probability]) => {
const like = this.likelihood(theta);
num += theta * like * probability;
nf += like * probability;
});
return num / nf;
}
estimateAbilityMLE() {
const theta0 = [0];
const solution = (0, optimization_js_1.minimize_Powell)(this.negLikelihood.bind(this), theta0);
let theta = solution.argument[0];
if (theta > this.maxTheta) {
theta = this.maxTheta;
}
else if (theta < this.minTheta) {
theta = this.minTheta;
}
return theta;
}
negLikelihood(thetaArray) {
return -this.likelihood(thetaArray[0]);
}
likelihood(theta) {
return this._zetas.reduce((acc, zeta, i) => {
const irf = (0, utils_1.itemResponseFunction)(theta, zeta);
return this._resps[i] === 1 ? acc + Math.log(irf) : acc + Math.log(1 - irf);
}, 1);
}
/**
* calculate the standard error of ability estimation
*/
calculateSE() {
const sum = this._zetas.reduce((previousValue, zeta) => previousValue + (0, utils_1.fisherInformation)(this._theta, zeta), 0);
this._seMeasurement = 1 / Math.sqrt(sum);
}
/**
* find the next available item from an input array of stimuli based on a selection method
*
* remainingStimuli is sorted by fisher information to reduce the computation complexity for future item selection
* @param stimuli - an array of stimulus
* @param itemSelect - the item selection method
* @param deepCopy - default deepCopy = true
* @returns {nextStimulus: Stimulus,
remainingStimuli: Array<Stimulus>}
*/
findNextItem(stimuli, itemSelect = this.itemSelect, deepCopy = true) {
let arr;
let selector = Cat.validateItemSelect(itemSelect);
if (deepCopy) {
arr = (0, lodash_1.cloneDeep)(stimuli);
}
else {
arr = stimuli;
}
if (this.nItems < this.nStartItems) {
selector = this.startSelect;
}
if (selector !== 'mfi') {
// for mfi, we sort the arr by fisher information in the private function to select the best item,
// and then sort by difficulty to return the remainingStimuli
arr.sort((a, b) => a.difficulty - b.difficulty);
}
if (selector === 'middle') {
// middle will only be used in startSelect
return this.selectorMiddle(arr);
}
else if (selector === 'closest') {
return this.selectorClosest(arr);
}
else if (selector === 'random') {
return this.selectorRandom(arr);
}
else {
return this.selectorMFI(arr);
}
}
selectorMFI(arr) {
const stimuliAddFisher = arr.map((element) => (Object.assign({ fisherInformation: (0, utils_1.fisherInformation)(this._theta, {
a: element.a || 1,
b: element.difficulty || 0,
c: element.c || 0,
d: element.d || 1,
}) }, element)));
stimuliAddFisher.sort((a, b) => b.fisherInformation - a.fisherInformation);
stimuliAddFisher.forEach((stimulus) => {
delete stimulus['fisherInformation'];
});
return {
nextStimulus: stimuliAddFisher[0],
remainingStimuli: stimuliAddFisher.slice(1).sort((a, b) => a.difficulty - b.difficulty),
};
}
selectorMiddle(arr) {
let index;
if (arr.length < this.nStartItems) {
index = Math.floor(arr.length / 2);
}
else {
index =
Math.floor(arr.length / 2) +
this.randomInteger(-Math.floor(this.nStartItems / 2), Math.floor(this.nStartItems / 2));
}
const nextItem = arr[index];
arr.splice(index, 1);
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
selectorClosest(arr) {
//findClosest requires arr is sorted by difficulty
const index = (0, utils_1.findClosest)(arr, this._theta + 0.481);
const nextItem = arr[index];
arr.splice(index, 1);
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
selectorRandom(arr) {
const index = Math.floor(this._rng() * arr.length);
const nextItem = arr.splice(index, 1)[0];
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
/**
* return a random integer between min and max
* @param min - The minimum of the random number range (include)
* @param max - The maximum of the random number range (include)
* @returns {number} - random integer within the range
*/
randomInteger(min, max) {
return Math.floor(this._rng() * (max - min + 1)) + min;
}
}
exports.Cat = Cat;
exports.StopIfSEMeasurementBelowThreshold = exports.StopOnSEMeasurementPlateau = exports.StopAfterNItems = exports.EarlyStopping = exports.prepareClowderCorpus = exports.Clowder = exports.Cat = void 0;
var cat_1 = require("./cat");
Object.defineProperty(exports, "Cat", { enumerable: true, get: function () { return cat_1.Cat; } });
var clowder_1 = require("./clowder");
Object.defineProperty(exports, "Clowder", { enumerable: true, get: function () { return clowder_1.Clowder; } });
var corpus_1 = require("./corpus");
Object.defineProperty(exports, "prepareClowderCorpus", { enumerable: true, get: function () { return corpus_1.prepareClowderCorpus; } });
var stopping_1 = require("./stopping");
Object.defineProperty(exports, "EarlyStopping", { enumerable: true, get: function () { return stopping_1.EarlyStopping; } });
Object.defineProperty(exports, "StopAfterNItems", { enumerable: true, get: function () { return stopping_1.StopAfterNItems; } });
Object.defineProperty(exports, "StopOnSEMeasurementPlateau", { enumerable: true, get: function () { return stopping_1.StopOnSEMeasurementPlateau; } });
Object.defineProperty(exports, "StopIfSEMeasurementBelowThreshold", { enumerable: true, get: function () { return stopping_1.StopIfSEMeasurementBelowThreshold; } });

@@ -1,2 +0,2 @@

export declare type Zeta = {
export declare type ZetaSymbolic = {
a: number;

@@ -7,5 +7,25 @@ b: number;

};
export interface Stimulus {
difficulty: number;
export interface Zeta {
a?: number;
b?: number;
c?: number;
d?: number;
discrimination?: number;
difficulty?: number;
guessing?: number;
slipping?: number;
}
export interface Stimulus extends Zeta {
[key: string]: any;
}
export declare type ZetaCatMap = {
cats: string[];
zeta: Zeta;
};
export interface MultiZetaStimulus {
zetas: ZetaCatMap[];
[key: string]: any;
}
export declare type CatMap<T> = {
[name: string]: T;
};
import { Stimulus, Zeta } from './type';
/**
* calculates the probability that someone with a given ability level theta will answer correctly an item. Uses the 4 parameters logistic model
* @param theta - ability estimate
* @param zeta - item params
* Calculates the probability that someone with a given ability level theta will
* answer correctly an item. Uses the 4 parameters logistic model
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} the probability

@@ -10,5 +12,6 @@ */

/**
* a 3PL Fisher information function
* @param theta - ability estimate
* @param zeta - item params
* A 3PL Fisher information function
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} - the expected value of the observed information

@@ -18,8 +21,9 @@ */

/**
* return a Gaussian distribution within a given range
* @param mean
* @param stdDev
* @param min
* @param max
* @param stepSize - the quantization (step size) of the internal table, default = 0.1
* Return a Gaussian distribution within a given range
*
* @param {number} mean
* @param {number} stdDev
* @param {number} min
* @param {number} max
* @param {number} stepSize - the quantization (step size) of the internal table, default = 0.1
* @returns {Array<[number, number]>} - a normal distribution

@@ -29,3 +33,3 @@ */

/**
* find the item in a given array that has the difficulty closest to the target value
* Find the item in a given array that has the difficulty closest to the target value
*

@@ -35,6 +39,6 @@ * @remarks

*
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty
* @param target number - ability estimate
* @returns {number} the index of arr
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty
* @param {number} target - ability estimate
* @returns {number} the index of stimuli
*/
export declare const findClosest: (arr: Array<Stimulus>, target: number) => number;
export declare const findClosest: (inputStimuli: Array<Stimulus>, target: number) => number;

@@ -7,32 +7,40 @@ "use strict";

exports.findClosest = exports.normal = exports.fisherInformation = exports.itemResponseFunction = void 0;
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const binary_search_1 = __importDefault(require("binary-search"));
const corpus_1 = require("./corpus");
/**
* calculates the probability that someone with a given ability level theta will answer correctly an item. Uses the 4 parameters logistic model
* @param theta - ability estimate
* @param zeta - item params
* Calculates the probability that someone with a given ability level theta will
* answer correctly an item. Uses the 4 parameters logistic model
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} the probability
*/
const itemResponseFunction = (theta, zeta) => {
return zeta.c + (zeta.d - zeta.c) / (1 + Math.exp(-zeta.a * (theta - zeta.b)));
const _zeta = (0, corpus_1.fillZetaDefaults)(zeta, 'symbolic');
return _zeta.c + (_zeta.d - _zeta.c) / (1 + Math.exp(-_zeta.a * (theta - _zeta.b)));
};
exports.itemResponseFunction = itemResponseFunction;
/**
* a 3PL Fisher information function
* @param theta - ability estimate
* @param zeta - item params
* A 3PL Fisher information function
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} - the expected value of the observed information
*/
const fisherInformation = (theta, zeta) => {
const p = (0, exports.itemResponseFunction)(theta, zeta);
const _zeta = (0, corpus_1.fillZetaDefaults)(zeta, 'symbolic');
const p = (0, exports.itemResponseFunction)(theta, _zeta);
const q = 1 - p;
return Math.pow(zeta.a, 2) * (q / p) * (Math.pow(p - zeta.c, 2) / Math.pow(1 - zeta.c, 2));
return Math.pow(_zeta.a, 2) * (q / p) * (Math.pow(p - _zeta.c, 2) / Math.pow(1 - _zeta.c, 2));
};
exports.fisherInformation = fisherInformation;
/**
* return a Gaussian distribution within a given range
* @param mean
* @param stdDev
* @param min
* @param max
* @param stepSize - the quantization (step size) of the internal table, default = 0.1
* Return a Gaussian distribution within a given range
*
* @param {number} mean
* @param {number} stdDev
* @param {number} min
* @param {number} max
* @param {number} stepSize - the quantization (step size) of the internal table, default = 0.1
* @returns {Array<[number, number]>} - a normal distribution

@@ -52,3 +60,3 @@ */

/**
* find the item in a given array that has the difficulty closest to the target value
* Find the item in a given array that has the difficulty closest to the target value
*

@@ -58,13 +66,14 @@ * @remarks

*
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty
* @param target number - ability estimate
* @returns {number} the index of arr
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty
* @param {number} target - ability estimate
* @returns {number} the index of stimuli
*/
const findClosest = (arr, target) => {
const findClosest = (inputStimuli, target) => {
const stimuli = inputStimuli.map((stim) => (0, corpus_1.fillZetaDefaults)(stim, 'semantic'));
// Let's consider the edge cases first
if (target <= arr[0].difficulty) {
if (target <= stimuli[0].difficulty) {
return 0;
}
else if (target >= arr[arr.length - 1].difficulty) {
return arr.length - 1;
else if (target >= stimuli[stimuli.length - 1].difficulty) {
return stimuli.length - 1;
}

@@ -74,3 +83,3 @@ const comparitor = (element, needle) => {

};
const indexOfTarget = (0, binary_search_1.default)(arr, target, comparitor);
const indexOfTarget = (0, binary_search_1.default)(stimuli, target, comparitor);
if (indexOfTarget >= 0) {

@@ -88,4 +97,4 @@ // `bs` returns a positive integer index if it found an exact match.

// low values, respectively
const lowDiff = Math.abs(arr[lowIndex].difficulty - target);
const highDiff = Math.abs(arr[highIndex].difficulty - target);
const lowDiff = Math.abs(stimuli[lowIndex].difficulty - target);
const highDiff = Math.abs(stimuli[highIndex].difficulty - target);
if (lowDiff < highDiff) {

@@ -92,0 +101,0 @@ return lowIndex;

{
"name": "@bdelab/jscat",
"version": "4.0.0",
"version": "5.0.0",
"description": "A library to support IRT-based computer adaptive testing in JavaScript",

@@ -36,3 +36,3 @@ "main": "lib/index.js",

"devDependencies": {
"@types/jest": "^28.1.6",
"@types/jest": "^28.1.8",
"@types/lodash": "^4.14.182",

@@ -45,4 +45,5 @@ "@types/seedrandom": "^3.0.2",

"jest": "^28.1.3",
"jest-extended": "^4.0.2",
"prettier": "^2.7.1",
"ts-jest": "^28.0.7",
"ts-jest": "^28.0.8",
"tsdoc": "^0.0.4",

@@ -49,0 +50,0 @@ "typescript": "^4.7.4"

@@ -12,3 +12,3 @@ ---

orcid: 0000-0002-2686-1293
affiliation: "1, 2"
affiliation: "1, 2, 3"
- name: Adam Richie-Halford

@@ -18,7 +18,9 @@ orcid: 0000-0001-9276-9084

affiliations:
- name: # TODO (Anya) Add GSE affiliation
index: 1
- name: # TODO (Anya) Add DBP affiliation
- name: Stanford University Graduate School of Education
index: 1
- name: Stanford University School of Medicine, Division of Developmental Behavioral Pediatrics
index: 2
date: 13 March 2023
- name: Stanford University Department of Psychology
index: 3
date: 5 October 2023
bibliography: paper.bib

@@ -38,9 +40,7 @@ ---

jsCAT is a JavaScript library to support a fully client-side adaptive testing
environment. Computerized Adaptive Testing (CAT) is a type of assessment that
uses computer algorithms to dynamically adjust the difficulty of questions based
on a test-taker's performance. Although CAT has been widely researched and
applied in areas such as education and psychology (e.g., GRE and Duolingo),
designing a CAT solution suitable for large-scale school administration remains
a challenge. Imagine we are administering 500 children in a school at the same
jsCAT is a JavaScript library to support fully client-side computerized adaptive testing. Computerized Adaptive Testing
is an approach to assessment that uses an algorithm to select the most informative item to present to a test-taker
based on their previous responses. Although CAT has been widely researched and applied in areas such as education
and psychology (e.g., GRE and Duolingo), designing a CAT solution suitable for large-scale school administration
remains a challenge. Imagine we are administering 500 children in a school at the same
time, where reliable wifi is not guaranteed at all times, which means that we

@@ -84,2 +84,2 @@ should minimize data communications between the client and servers to prevent

<!-- TODO (Anya): Add references in the paper.bib file -->
<!-- TODO (Anya): Add references in the paper.bib file -->

@@ -19,2 +19,4 @@ [![Test and lint](https://github.com/yeatmanlab/jsCAT/actions/workflows/ci.yml/badge.svg)](https://github.com/yeatmanlab/jsCAT/actions/workflows/ci.yml)

For existing jsCAT users: to make your applications compatible to the updated jsCAT version, you will need to pass the stimuli in the following way:
```js

@@ -30,3 +32,11 @@ // import jsCAT

// update the abilitiy estimate by adding test items
// option 1 to input stimuli:
const zeta = {[{discrimination: 1, difficulty: 0, guessing: 0, slipping: 1}, {discrimination: 1, difficulty: 0.5, guessing: 0, slipping: 1}]}
// option 2 to input stimuli:
const zeta = {[{a: 1, b: 0, c: 0, d: 1}, {a: 1, b: 0.5, c: 0, d: 1}]}
const answer = {[1, 0]}
// update the ability estimate by adding test items
cat.updateAbilityEstimate(zeta, answer);

@@ -42,12 +52,245 @@

const stimuli = [{difficulty: -3, item: 'item1'}, {difficulty: -2, item: 'item2'}];
> **Note:** For existing jsCAT users: To make your applications compatible with the updated jsCAT version, you will need to pass the stimuli in the following way:
const stimuli = [{ discrimination: 1, difficulty: -2, guessing: 0, slipping: 1, item = "item1" },{ discrimination: 1, difficulty: 3, guessing: 0, slipping: 1, item = "item2" }];
const nextItem = cat.findNextItem(stimuli, 'MFI');
```
## Validation
### Validation of theta estimate and theta standard error
Reference software: mirt (Chalmers, 2012)
![img.png](validation/plots/jsCAT_validation_1.png)
### Validation of MFI algorithm
Reference software: catR (Magis et al., 2017)
![img_1.png](validation/plots/jsCAT_validation_2.png)
# Clowder Usage Guide
The `Clowder` class is a powerful tool for managing multiple `Cat` instances and handling stimuli corpora in adaptive testing scenarios. This guide provides an overview of integrating `Clowder` into your application, with examples and explanations for key features.
---
## Key Changes from Single `Cat` to `Clowder`
### Why Use Clowder?
- **Multi-CAT Support**: Manage multiple `Cat` instances simultaneously.
- **Centralized Corpus Management**: Handle validated and unvalidated items across Cats.
- **Advanced Trial Management**: Dynamically update Cats and retrieve stimuli based on configurable rules.
- **Early Stopping Mechanisms**: Optimize testing by integrating conditions to stop trials automatically.
---
## Transitioning to Clowder
### 1. Replacing Single `Cat` Usage
#### Single `Cat` Example:
```typescript
const cat = new Cat({ method: 'MLE', theta: 0.5 });
const nextItem = cat.findNextItem(stimuli);
```
#### Clowder Equivalent:
```typescript
const clowder = new Clowder({
cats: { cat1: { method: 'MLE', theta: 0.5 } },
corpus: stimuli,
});
const nextItem = clowder.updateCatAndGetNextItem({
catToSelect: 'cat1',
});
```
---
### 2. Using a Corpus with Multi-Zeta Stimuli
The `Clowder` corpus supports multi-zeta stimuli, allowing each stimulus to define parameters for multiple Cats. Use the following tools to prepare the corpus:
#### Fill Default Zeta Parameters:
```typescript
import { fillZetaDefaults } from './corpus';
const filledStimuli = stimuli.map((stim) => fillZetaDefaults(stim));
```
**What is `fillZetaDefaults`?**
The function `fillZetaDefaults` ensures that each stimulus in the corpus has Zeta parameters defined. If any parameters are missing, it fills them with the default Zeta values.
The default values are:
```typescript
export const defaultZeta = (desiredFormat: 'symbolic' | 'semantic' = 'symbolic'): Zeta => {
const defaultZeta: Zeta = {
a: 1,
b: 0,
c: 0,
d: 1,
};
return convertZeta(defaultZeta, desiredFormat);
};
```
- If desiredFormat is not specified, it defaults to 'symbolic'.
- This ensures consistency across different stimuli and prevents errors from missing Zeta parameters.
- You can pass 'semantic' as an argument to convert the default Zeta values into a different representation.
#### Validate the Corpus:
```typescript
import { checkNoDuplicateCatNames } from './corpus';
checkNoDuplicateCatNames(corpus);
```
#### Filter Stimuli for a Specific Cat:
```typescript
import { filterItemsByCatParameterAvailability } from './corpus';
const { available, missing } = filterItemsByCatParameterAvailability(corpus, 'cat1');
```
---
### 3. Adding Early Stopping
Integrate early stopping mechanisms to optimize the testing process.
#### Example: Stop After N Items
```typescript
import { StopAfterNItems } from './stopping';
const earlyStopping = new StopAfterNItems({
requiredItems: { cat1: 2 },
});
const clowder = new Clowder({
cats: { cat1: { method: 'MLE', theta: 0.5 } },
corpus: stimuli,
earlyStopping: earlyStopping,
});
```
## Early Stopping Criteria Combinations
To clarify the available combinations for early stopping, here’s a breakdown of the options you can use:
### 1. Logical Operations
You can combine multiple stopping criteria using one of the following logical operations:
- **`and`**: All conditions need to be met to trigger early stopping.
- **`or`**: Any one condition being met will trigger early stopping.
- **`only`**: Only a specific condition is considered (you need to specify the cat to evaluate).
### 2. Stopping Criteria Classes
There are different types of stopping criteria you can configure:
- **`StopAfterNItems`**: Stops the process after a specified number of items.
- **`StopOnSEMeasurementPlateau`**: Stops if the standard error (SE) of measurement remains stable (within a defined tolerance) for a specified number of items.
- **`StopIfSEMeasurementBelowThreshold`**: Stops if the SE measurement drops below a set threshold.
### How Combinations Work
You can mix and match these criteria with different logical operations, giving you a range of configurations for early stopping. For example:
- Using **`and`** with both `StopAfterNItems` and `StopIfSEMeasurementBelowThreshold` means stopping will only occur if both conditions are satisfied.
- Using **`or`** with `StopOnSEMeasurementPlateau` and `StopAfterNItems` allows early stopping if either condition is met.
---
## Clowder Example
Here’s a complete example demonstrating how to configure and use `Clowder`:
```typescript
import { Clowder } from './clowder';
import { createMultiZetaStimulus, createZetaCatMap } from './utils';
import { StopAfterNItems } from './stopping';
// Define the Cats
const catConfigs = {
cat1: { method: 'MLE', theta: 0.5 }, // Cat1 uses Maximum Likelihood Estimation
cat2: { method: 'EAP', theta: -1.0 }, // Cat2 uses Expected A Posteriori
};
// Define the corpus
const corpus = [
createMultiZetaStimulus('item1', [
createZetaCatMap(['cat1'], { a: 1, b: 0.5, c: 0.2, d: 0.8 }),
createZetaCatMap(['cat2'], { a: 2, b: 0.7, c: 0.3, d: 0.9 }),
]),
createMultiZetaStimulus('item2', [createZetaCatMap(['cat1'], { a: 1.5, b: 0.4, c: 0.1, d: 0.85 })]),
createMultiZetaStimulus('item3', [createZetaCatMap(['cat2'], { a: 2.5, b: 0.6, c: 0.25, d: 0.95 })]),
createMultiZetaStimulus('item4', []), // Unvalidated item
];
// Optional: Add an early stopping strategy
const earlyStopping = new StopAfterNItems({
requiredItems: { cat1: 2, cat2: 2 },
});
// Initialize the Clowder
const clowder = new Clowder({
cats: catConfigs,
corpus: corpus,
earlyStopping: earlyStopping,
});
// Running Trials
const nextItem = clowder.updateCatAndGetNextItem({
catToSelect: 'cat1',
catsToUpdate: ['cat1', 'cat2'], // Update responses for both Cats
items: [clowder.corpus[0]], // Previously seen item
answers: [1], // Response for the previously seen item
});
console.log('Next item to present:', nextItem);
// Check stopping condition
if (clowder.earlyStopping?.earlyStop) {
console.log('Early stopping triggered:', clowder.stoppingReason);
}
```
---
By integrating `Clowder`, your application can efficiently manage adaptive testing scenarios with robust trial and stimuli handling, multi-CAT configurations, and stopping conditions to ensure optimal performance.
## References
Lucas Duailibe, irt-js, (2019), GitHub repository, https://github.com/geekie/irt-js
- Chalmers, R. P. (2012). mirt: A multidimensional item response theory package for the R environment. Journal of Statistical Software.
- Magis, D., & Barrada, J. R. (2017). Computerized adaptive testing with R: Recent updates of the package catR. Journal of Statistical Software, 76, 1-19.
- Lucas Duailibe, irt-js, (2019), GitHub repository, https://github.com/geekie/irt-js
## License
jsCAT is distributed under the [ISC license](LICENSE).
## Contributors
jsCAT is contributed by Wanjing Anya Ma, Emily Judith Arteaga Garcia, Jason D. Yeatman, and Adam Richie-Halford.
## Citation
If you are use jsCAT for your web applications, please cite us:
Ma, W. A., Richie-Halford, A., Burkhardt, A. K., Kanopka, K., Chou, C., Domingue, B. W., & Yeatman, J. D. (2025). ROAR-CAT: Rapid Online Assessment of Reading ability with Computerized Adaptive Testing. Behavior Research Methods, 57(1), 1-17. https://doi.org/10.3758/s13428-024-02578-y
@article{ma2025roar,
title={ROAR-CAT: Rapid Online Assessment of Reading ability with Computerized Adaptive Testing},
author={Ma, Wanjing Anya and Richie-Halford, Adam and Burkhardt, Amy K and Kanopka, Klint and Chou, Clementine and Domingue, Benjamin W and Yeatman, Jason D},
journal={Behavior Research Methods},
volume={57},
number={1},
pages={1--17},
year={2025},
publisher={Springer}
}

@@ -5,7 +5,5 @@ import { itemResponseFunction, fisherInformation, findClosest } from '../utils';

it('correctly calculates the probability', () => {
expect(0.7234).toBeCloseTo(itemResponseFunction(0, { a: 1, b: -0.3, c: 0.35, d: 1 }), 2);
expect(0.5).toBeCloseTo(itemResponseFunction(0, { a: 1, b: 0, c: 0, d: 1 }), 2);
expect(0.625).toBeCloseTo(itemResponseFunction(0, { a: 0.5, b: 0, c: 0.25, d: 1 }), 2);
expect(itemResponseFunction(0, { a: 1, b: -0.3, c: 0.35, d: 1 })).toBeCloseTo(0.7234, 2);
expect(itemResponseFunction(0, { a: 1, b: 0, c: 0, d: 1 })).toBeCloseTo(0.5, 2);
expect(itemResponseFunction(0, { a: 0.5, b: 0, c: 0.25, d: 1 })).toBeCloseTo(0.625, 2);
});

@@ -16,5 +14,4 @@ });

it('correctly calculates the information', () => {
expect(0.206).toBeCloseTo(fisherInformation(0, { a: 1.53, b: -0.5, c: 0.5, d: 1 }), 2);
expect(0.1401).toBeCloseTo(fisherInformation(2.35, { a: 1, b: 2, c: 0.3, d: 1 }), 2);
expect(fisherInformation(0, { a: 1.53, b: -0.5, c: 0.5, d: 1 })).toBeCloseTo(0.206, 2);
expect(fisherInformation(2.35, { a: 1, b: 2, c: 0.3, d: 1 })).toBeCloseTo(0.1401, 2);
});

@@ -24,21 +21,40 @@ });

describe('findClosest', () => {
const stimuli = [
{ difficulty: 1, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 4, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 10, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 11, discrimination: 1, guessing: 0.25, slipping: 0.75 },
];
it('correctly selects the first item if appropriate', () => {
expect(0).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 0));
expect(findClosest(stimuli, 0)).toBe(0);
});
it('correctly selects the last item if appropriate', () => {
expect(3).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 1000));
expect(findClosest(stimuli, 1000)).toBe(3);
});
it('correctly selects a middle item if it equals exactly', () => {
expect(2).toBe(findClosest([{ difficulty: 1 }, { difficulty: 4 }, { difficulty: 10 }, { difficulty: 11 }], 10));
expect(findClosest(stimuli, 10)).toBe(2);
});
it('correctly selects the one item closest to the target if less than', () => {
expect(1).toBe(
findClosest([{ difficulty: 1.1 }, { difficulty: 4.2 }, { difficulty: 10.3 }, { difficulty: 11.4 }], 5.1),
);
const stimuliWithDecimal = [
{ difficulty: 1.1, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 4.2, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 10.3, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 11.4, discrimination: 1, guessing: 0.25, slipping: 0.75 },
];
expect(findClosest(stimuliWithDecimal, 5.1)).toBe(1);
});
it('correctly selects the one item closest to the target if greater than', () => {
expect(2).toBe(
findClosest([{ difficulty: 1.1 }, { difficulty: 4.2 }, { difficulty: 10.3 }, { difficulty: 11.4 }], 9.1),
);
const stimuliWithDecimal = [
{ difficulty: 1.1, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 4.2, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 10.3, discrimination: 1, guessing: 0.25, slipping: 0.75 },
{ difficulty: 11.4, discrimination: 1, guessing: 0.25, slipping: 0.75 },
];
expect(findClosest(stimuliWithDecimal, 9.1)).toBe(2);
});
});

@@ -1,301 +0,9 @@

import { minimize_Powell } from 'optimization-js';
import { cloneDeep } from 'lodash';
import { Stimulus, Zeta } from './type';
import { itemResponseFunction, fisherInformation, normal, findClosest } from './utils';
import seedrandom from 'seedrandom';
export const abilityPrior = normal();
export interface CatInput {
method?: string;
itemSelect?: string;
nStartItems?: number;
startSelect?: string;
theta?: number;
minTheta?: number;
maxTheta?: number;
prior?: number[][];
randomSeed?: string | null;
}
export class Cat {
public method: string;
public itemSelect: string;
public minTheta: number;
public maxTheta: number;
public prior: number[][];
private readonly _zetas: Zeta[];
private readonly _resps: (0 | 1)[];
private _nItems: number;
private _theta: number;
private _seMeasurement: number;
public nStartItems: number;
public startSelect: string;
private readonly _rng: ReturnType<seedrandom>;
/**
* Create a Cat object. This expects an single object parameter with the following keys
* @param {{method: string, itemSelect: string, nStartItems: number, startSelect:string, theta: number, minTheta: number, maxTheta: number, prior: number[][]}=} destructuredParam
* method: ability estimator, e.g. MLE or EAP, default = 'MLE'
* itemSelect: the method of item selection, e.g. "MFI", "random", "closest", default method = 'MFI'
* nStartItems: first n trials to keep non-adaptive selection
* startSelect: rule to select first n trials
* theta: initial theta estimate
* minTheta: lower bound of theta
* maxTheta: higher bound of theta
* prior: the prior distribution
* randomSeed: set a random seed to trace the simulation
*/
constructor({
method = 'MLE',
itemSelect = 'MFI',
nStartItems = 0,
startSelect = 'middle',
theta = 0,
minTheta = -6,
maxTheta = 6,
prior = abilityPrior,
randomSeed = null,
}: CatInput = {}) {
this.method = Cat.validateMethod(method);
this.itemSelect = Cat.validateItemSelect(itemSelect);
this.startSelect = Cat.validateStartSelect(startSelect);
this.minTheta = minTheta;
this.maxTheta = maxTheta;
this.prior = prior;
this._zetas = [];
this._resps = [];
this._theta = theta;
this._nItems = 0;
this._seMeasurement = Number.MAX_VALUE;
this.nStartItems = nStartItems;
this._rng = randomSeed === null ? seedrandom() : seedrandom(randomSeed);
}
public get theta() {
return this._theta;
}
public get seMeasurement() {
return this._seMeasurement;
}
public get nItems() {
return this._resps.length;
}
public get resps() {
return this._resps;
}
public get zetas() {
return this._zetas;
}
private static validateMethod(method: string) {
const lowerMethod = method.toLowerCase();
const validMethods: Array<string> = ['mle', 'eap']; // TO DO: add staircase
if (!validMethods.includes(lowerMethod)) {
throw new Error('The abilityEstimator you provided is not in the list of valid methods');
}
return lowerMethod;
}
private static validateItemSelect(itemSelect: string) {
const lowerItemSelect = itemSelect.toLowerCase();
const validItemSelect: Array<string> = ['mfi', 'random', 'closest'];
if (!validItemSelect.includes(lowerItemSelect)) {
throw new Error('The itemSelector you provided is not in the list of valid methods');
}
return lowerItemSelect;
}
private static validateStartSelect(startSelect: string) {
const lowerStartSelect = startSelect.toLowerCase();
const validStartSelect: Array<string> = ['random', 'middle']; // TO DO: add staircase
if (!validStartSelect.includes(lowerStartSelect)) {
throw new Error('The startSelect you provided is not in the list of valid methods');
}
return lowerStartSelect;
}
/**
* use previous response patterns and item params to calculate the estimate ability based on a defined method
* @param zeta - last item param
* @param answer - last response pattern
* @param method
*/
public updateAbilityEstimate(zeta: Zeta | Zeta[], answer: (0 | 1) | (0 | 1)[], method: string = this.method) {
method = Cat.validateMethod(method);
zeta = Array.isArray(zeta) ? zeta : [zeta];
answer = Array.isArray(answer) ? answer : [answer];
if (zeta.length !== answer.length) {
throw new Error('Unmatched length between answers and item params');
}
this._zetas.push(...zeta);
this._resps.push(...answer);
if (method === 'eap') {
this._theta = this.estimateAbilityEAP();
} else if (method === 'mle') {
this._theta = this.estimateAbilityMLE();
}
this.calculateSE();
}
private estimateAbilityEAP() {
let num = 0;
let nf = 0;
this.prior.forEach(([theta, probability]) => {
const like = this.likelihood(theta);
num += theta * like * probability;
nf += like * probability;
});
return num / nf;
}
private estimateAbilityMLE() {
const theta0 = [0];
const solution = minimize_Powell(this.negLikelihood.bind(this), theta0);
let theta = solution.argument[0];
if (theta > this.maxTheta) {
theta = this.maxTheta;
} else if (theta < this.minTheta) {
theta = this.minTheta;
}
return theta;
}
private negLikelihood(thetaArray: Array<number>) {
return -this.likelihood(thetaArray[0]);
}
private likelihood(theta: number) {
return this._zetas.reduce((acc, zeta, i) => {
const irf = itemResponseFunction(theta, zeta);
return this._resps[i] === 1 ? acc + Math.log(irf) : acc + Math.log(1 - irf);
}, 1);
}
/**
* calculate the standard error of ability estimation
*/
private calculateSE() {
const sum = this._zetas.reduce((previousValue, zeta) => previousValue + fisherInformation(this._theta, zeta), 0);
this._seMeasurement = 1 / Math.sqrt(sum);
}
/**
* find the next available item from an input array of stimuli based on a selection method
*
* remainingStimuli is sorted by fisher information to reduce the computation complexity for future item selection
* @param stimuli - an array of stimulus
* @param itemSelect - the item selection method
* @param deepCopy - default deepCopy = true
* @returns {nextStimulus: Stimulus,
remainingStimuli: Array<Stimulus>}
*/
public findNextItem(stimuli: Stimulus[], itemSelect: string = this.itemSelect, deepCopy = true) {
let arr: Array<Stimulus>;
let selector = Cat.validateItemSelect(itemSelect);
if (deepCopy) {
arr = cloneDeep(stimuli);
} else {
arr = stimuli;
}
if (this.nItems < this.nStartItems) {
selector = this.startSelect;
}
if (selector !== 'mfi') {
// for mfi, we sort the arr by fisher information in the private function to select the best item,
// and then sort by difficulty to return the remainingStimuli
arr.sort((a: Stimulus, b: Stimulus) => a.difficulty - b.difficulty);
}
if (selector === 'middle') {
// middle will only be used in startSelect
return this.selectorMiddle(arr);
} else if (selector === 'closest') {
return this.selectorClosest(arr);
} else if (selector === 'random') {
return this.selectorRandom(arr);
} else {
return this.selectorMFI(arr);
}
}
private selectorMFI(arr: Stimulus[]) {
const stimuliAddFisher = arr.map((element: Stimulus) => ({
fisherInformation: fisherInformation(this._theta, {
a: element.a || 1,
b: element.difficulty || 0,
c: element.c || 0,
d: element.d || 1,
}),
...element,
}));
stimuliAddFisher.sort((a, b) => b.fisherInformation - a.fisherInformation);
stimuliAddFisher.forEach((stimulus: Stimulus) => {
delete stimulus['fisherInformation'];
});
return {
nextStimulus: stimuliAddFisher[0],
remainingStimuli: stimuliAddFisher.slice(1).sort((a: Stimulus, b: Stimulus) => a.difficulty - b.difficulty),
};
}
private selectorMiddle(arr: Stimulus[]) {
let index: number;
if (arr.length < this.nStartItems) {
index = Math.floor(arr.length / 2);
} else {
index =
Math.floor(arr.length / 2) +
this.randomInteger(-Math.floor(this.nStartItems / 2), Math.floor(this.nStartItems / 2));
}
const nextItem = arr[index];
arr.splice(index, 1);
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
private selectorClosest(arr: Stimulus[]) {
//findClosest requires arr is sorted by difficulty
const index = findClosest(arr, this._theta + 0.481);
const nextItem = arr[index];
arr.splice(index, 1);
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
private selectorRandom(arr: Stimulus[]) {
const index = Math.floor(this._rng() * arr.length);
const nextItem = arr.splice(index, 1)[0];
return {
nextStimulus: nextItem,
remainingStimuli: arr,
};
}
/**
* return a random integer between min and max
* @param min - The minimum of the random number range (include)
* @param max - The maximum of the random number range (include)
* @returns {number} - random integer within the range
*/
private randomInteger(min: number, max: number) {
return Math.floor(this._rng() * (max - min + 1)) + min;
}
}
export { Cat, CatInput } from './cat';
export { Clowder, ClowderInput } from './clowder';
export { prepareClowderCorpus } from './corpus';
export {
EarlyStopping,
StopAfterNItems,
StopOnSEMeasurementPlateau,
StopIfSEMeasurementBelowThreshold,
} from './stopping';

@@ -1,7 +0,40 @@

export type Zeta = { a: number; b: number; c: number; d: number };
export type ZetaSymbolic = {
// Symbolic parameter names
a: number; // Discrimination (slope of the curve)
b: number; // Difficulty (location of the curve)
c: number; // Guessing (lower asymptote)
d: number; // Slipping (upper asymptote)
};
export interface Stimulus {
difficulty: number;
export interface Zeta {
// Symbolic parameter names
a?: number; // Discrimination (slope of the curve)
b?: number; // Difficulty (location of the curve)
c?: number; // Guessing (lower asymptote)
d?: number; // Slipping (upper asymptote)
// Semantic parameter names
discrimination?: number;
difficulty?: number;
guessing?: number;
slipping?: number;
}
export interface Stimulus extends Zeta {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export type ZetaCatMap = {
cats: string[];
zeta: Zeta;
};
export interface MultiZetaStimulus {
zetas: ZetaCatMap[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export type CatMap<T> = {
[name: string]: T;
};

@@ -0,33 +1,41 @@

/* eslint-disable @typescript-eslint/no-non-null-assertion */
import bs from 'binary-search';
import { Stimulus, Zeta } from './type';
import { Stimulus, Zeta, ZetaSymbolic } from './type';
import { fillZetaDefaults } from './corpus';
/**
* calculates the probability that someone with a given ability level theta will answer correctly an item. Uses the 4 parameters logistic model
* @param theta - ability estimate
* @param zeta - item params
* Calculates the probability that someone with a given ability level theta will
* answer correctly an item. Uses the 4 parameters logistic model
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} the probability
*/
export const itemResponseFunction = (theta: number, zeta: Zeta) => {
return zeta.c + (zeta.d - zeta.c) / (1 + Math.exp(-zeta.a * (theta - zeta.b)));
const _zeta = fillZetaDefaults(zeta, 'symbolic') as ZetaSymbolic;
return _zeta.c + (_zeta.d - _zeta.c) / (1 + Math.exp(-_zeta.a * (theta - _zeta.b)));
};
/**
* a 3PL Fisher information function
* @param theta - ability estimate
* @param zeta - item params
* A 3PL Fisher information function
*
* @param {number} theta - ability estimate
* @param {Zeta} zeta - item params
* @returns {number} - the expected value of the observed information
*/
export const fisherInformation = (theta: number, zeta: Zeta) => {
const p = itemResponseFunction(theta, zeta);
const _zeta = fillZetaDefaults(zeta, 'symbolic') as ZetaSymbolic;
const p = itemResponseFunction(theta, _zeta);
const q = 1 - p;
return Math.pow(zeta.a, 2) * (q / p) * (Math.pow(p - zeta.c, 2) / Math.pow(1 - zeta.c, 2));
return Math.pow(_zeta.a, 2) * (q / p) * (Math.pow(p - _zeta.c, 2) / Math.pow(1 - _zeta.c, 2));
};
/**
* return a Gaussian distribution within a given range
* @param mean
* @param stdDev
* @param min
* @param max
* @param stepSize - the quantization (step size) of the internal table, default = 0.1
* Return a Gaussian distribution within a given range
*
* @param {number} mean
* @param {number} stdDev
* @param {number} min
* @param {number} max
* @param {number} stepSize - the quantization (step size) of the internal table, default = 0.1
* @returns {Array<[number, number]>} - a normal distribution

@@ -48,3 +56,3 @@ */

/**
* find the item in a given array that has the difficulty closest to the target value
* Find the item in a given array that has the difficulty closest to the target value
*

@@ -54,18 +62,19 @@ * @remarks

*
* @param arr Array<Stimulus> - an array of stimuli sorted by difficulty
* @param target number - ability estimate
* @returns {number} the index of arr
* @param {Stimulus[]} inputStimuli - an array of stimuli sorted by difficulty
* @param {number} target - ability estimate
* @returns {number} the index of stimuli
*/
export const findClosest = (arr: Array<Stimulus>, target: number) => {
export const findClosest = (inputStimuli: Array<Stimulus>, target: number) => {
const stimuli = inputStimuli.map((stim) => fillZetaDefaults(stim, 'semantic'));
// Let's consider the edge cases first
if (target <= arr[0].difficulty) {
if (target <= stimuli[0].difficulty!) {
return 0;
} else if (target >= arr[arr.length - 1].difficulty) {
return arr.length - 1;
} else if (target >= stimuli[stimuli.length - 1].difficulty!) {
return stimuli.length - 1;
}
const comparitor = (element: Stimulus, needle: number) => {
return element.difficulty - needle;
return element.difficulty! - needle;
};
const indexOfTarget = bs(arr, target, comparitor);
const indexOfTarget = bs(stimuli, target, comparitor);

@@ -84,4 +93,4 @@ if (indexOfTarget >= 0) {

// low values, respectively
const lowDiff = Math.abs(arr[lowIndex].difficulty - target);
const highDiff = Math.abs(arr[highIndex].difficulty - target);
const lowDiff = Math.abs(stimuli[lowIndex].difficulty! - target);
const highDiff = Math.abs(stimuli[highIndex].difficulty! - target);

@@ -88,0 +97,0 @@ if (lowDiff < highDiff) {

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc