UNPKG

@bdelab/jscat

Version:

A library to support IRT-based computer adaptive testing in JavaScript

849 lines (764 loc) 23.9 kB
import { Cat } from '..'; import { CatMap } from '../type'; import { EarlyStopping, StopAfterNItems, StopIfSEMeasurementBelowThreshold, StopOnSEMeasurementPlateau } from '../'; import { StopAfterNItemsInput, StopIfSEMeasurementBelowThresholdInput, StopOnSEMeasurementPlateauInput, } from '../stopping'; import { toBeBoolean } from 'jest-extended'; expect.extend({ toBeBoolean }); /* eslint-disable @typescript-eslint/no-explicit-any */ type Class<T> = new (...args: any[]) => T; const testLogicalOperationValidation = ( stoppingClass: Class<StopAfterNItems | StopIfSEMeasurementBelowThreshold | StopOnSEMeasurementPlateau>, input: StopAfterNItemsInput | StopIfSEMeasurementBelowThresholdInput | StopOnSEMeasurementPlateauInput, ) => { expect(() => new stoppingClass(input)).toThrowError( `Invalid logical operation. Expected "and", "or", or "only". Received "${input.logicalOperation}"`, ); }; const testInstantiation = ( earlyStopping: EarlyStopping, input: StopAfterNItemsInput | StopIfSEMeasurementBelowThresholdInput | StopOnSEMeasurementPlateauInput, ) => { if (earlyStopping instanceof StopAfterNItems) { expect(earlyStopping.requiredItems).toEqual((input as StopAfterNItems).requiredItems ?? {}); } if ( earlyStopping instanceof StopOnSEMeasurementPlateau || earlyStopping instanceof StopIfSEMeasurementBelowThreshold ) { expect(earlyStopping.patience).toEqual((input as StopOnSEMeasurementPlateauInput).patience ?? {}); expect(earlyStopping.tolerance).toEqual((input as StopOnSEMeasurementPlateauInput).tolerance ?? {}); } if (earlyStopping instanceof StopIfSEMeasurementBelowThreshold) { expect(earlyStopping.seMeasurementThreshold).toEqual( (input as StopIfSEMeasurementBelowThresholdInput).seMeasurementThreshold ?? {}, ); } expect(earlyStopping.logicalOperation).toBe(input.logicalOperation?.toLowerCase() ?? 'or'); expect(earlyStopping.earlyStop).toBeBoolean(); }; const testInternalState = (earlyStopping: EarlyStopping) => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 0.5, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.nItems.cat1).toBe(1); expect(earlyStopping.seMeasurements.cat1).toEqual([0.5]); expect(earlyStopping.nItems.cat2).toBe(1); expect(earlyStopping.seMeasurements.cat2).toEqual([0.3]); earlyStopping.update(updates[1]); expect(earlyStopping.nItems.cat1).toBe(2); expect(earlyStopping.seMeasurements.cat1).toEqual([0.5, 0.5]); expect(earlyStopping.nItems.cat2).toBe(2); expect(earlyStopping.seMeasurements.cat2).toEqual([0.3, 0.3]); }; describe.each` logicalOperation ${'and'} ${'or'} `("StopOnSEMeasurementPlateau (with logicalOperation='$logicalOperation'", ({ logicalOperation }) => { let earlyStopping: StopOnSEMeasurementPlateau; let input: StopOnSEMeasurementPlateauInput; beforeEach(() => { input = { patience: { cat1: 2, cat2: 3 }, tolerance: { cat1: 0.01, cat2: 0.02 }, logicalOperation, }; earlyStopping = new StopOnSEMeasurementPlateau(input); }); it('instantiates with input parameters', () => testInstantiation(earlyStopping, input)); it('validates input', () => testLogicalOperationValidation(StopOnSEMeasurementPlateau, { ...input, logicalOperation: 'invalid' as 'and' })); it('updates internal state when new measurements are added', () => testInternalState(earlyStopping)); it('stops when the seMeasurement has plateaued', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { // cat1 should trigger stopping if logicalOperator === 'or', because // seMeasurement plateaued over the patience period of 2 items cat1: { nItems: 2, seMeasurement: 0.5, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.5, } as Cat, // cat2 should trigger stopping if logicalOperator === 'and', because // seMeasurement plateaued over the patience period of 3 items, and the // cat1 criterion passed last update cat2: { nItems: 3, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'or') { earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(true); } else { earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(true); } }); it('does not stop when the seMeasurement has not plateaued', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 100, } as Cat, cat2: { nItems: 1, seMeasurement: 100, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 10, } as Cat, cat2: { nItems: 2, seMeasurement: 10, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 1, } as Cat, cat2: { nItems: 3, seMeasurement: 1, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); }); it('waits for `patience` items to monitor the seMeasurement plateau', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 100, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 10, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 10, } as Cat, cat2: { nItems: 3, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(true); }); it('triggers early stopping when within tolerance', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 10, } as Cat, cat2: { nItems: 1, seMeasurement: 0.4, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 1, } as Cat, cat2: { nItems: 2, seMeasurement: 0.395, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.99, } as Cat, cat2: { nItems: 3, seMeasurement: 0.39, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(true); }); }); describe.each` logicalOperation ${'and'} ${'or'} `("StopAfterNItems (with logicalOperation='$logicalOperation'", ({ logicalOperation }) => { let earlyStopping: StopAfterNItems; let input: StopAfterNItemsInput; beforeEach(() => { input = { requiredItems: { cat1: 2, cat2: 3 }, logicalOperation, }; earlyStopping = new StopAfterNItems(input); }); it('instantiates with input parameters', () => testInstantiation(earlyStopping, input)); it('validates input', () => testLogicalOperationValidation(StopAfterNItems, { ...input, logicalOperation: 'invalid' as 'and' })); it('updates internal state when new measurements are added', () => testInternalState(earlyStopping)); it('does not step when it has not seen required items', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { // Do not increment nItems for cat1 nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { // Do not increment nItems for cat1 nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { // Do not increment nItems for cat2 nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { // Increment nItems for cat1, but only use this update if // logicalOperation is 'and'. Early stopping should still not be // triggered. nItems: 2, seMeasurement: 0.5, } as Cat, cat2: { // Do not increment nItems for cat2 nItems: 2, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'and') { earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(false); } }); it('stops when it has seen required items', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { // Do not increment nItems for cat1 nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { // Do not increment nItems for cat1 nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { // Cat2 reaches required items nItems: 3, seMeasurement: 0.3, } as Cat, }, { cat1: { // Cat1 reaches required items nItems: 2, seMeasurement: 0.5, } as Cat, cat2: { // Cat2 reaches required items nItems: 3, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'or') { earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(true); } else { earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(true); } }); }); describe('EarlyStopping with logicalOperation "only"', () => { let earlyStopping: StopOnSEMeasurementPlateau; let input: StopOnSEMeasurementPlateauInput; beforeEach(() => { input = { patience: { cat1: 2, cat2: 3 }, tolerance: { cat1: 0.01, cat2: 0.02 }, logicalOperation: 'only', }; earlyStopping = new StopOnSEMeasurementPlateau(input); }); it('throws an error if catToSelect is not provided when logicalOperation is "only"', () => { expect(() => { earlyStopping.update({ cat1: { nItems: 1, seMeasurement: 0.5 } as any }, undefined); }).toThrowError('Must provide a cat to select for "only" stopping condition'); }); it('evaluates the stopping condition when catToSelect is in evaluationCats', () => { // Add updates to make sure cat1 is included in evaluationCats and has some measurements earlyStopping.update({ cat1: { nItems: 1, seMeasurement: 0.5 } as any }, 'cat1'); earlyStopping.update({ cat1: { nItems: 2, seMeasurement: 0.5 } as any }, 'cat1'); // Since 'cat1' is in evaluationCats, _earlyStop should be evaluated based on the stopping condition expect(earlyStopping.earlyStop).toBe(true); // Should be true because seMeasurement has plateaued }); it('sets _earlyStop to false when catToSelect is not in evaluationCats', () => { // Use 'cat3', which is not in the patience or tolerance maps (and thus not in evaluationCats) earlyStopping.update({ cat3: { nItems: 1, seMeasurement: 0.5 } as any }, 'cat3'); // Since 'cat3' is not in evaluationCats, _earlyStop should be false expect(earlyStopping.earlyStop).toBe(false); }); }); describe('StopIfSEMeasurementBelowThreshold with empty patience and tolerance', () => { let earlyStopping: StopIfSEMeasurementBelowThreshold; let input: StopIfSEMeasurementBelowThresholdInput; beforeEach(() => { input = { seMeasurementThreshold: { cat1: 0.03, cat2: 0.02 }, logicalOperation: 'only', }; earlyStopping = new StopIfSEMeasurementBelowThreshold(input); }); it('should handle updates correctly even with empty patience and tolerance', () => { // Update the state with some measurements for cat2, where seMeasurement is below the threshold earlyStopping.update({ cat2: { nItems: 1, seMeasurement: 0.01 } as any }, 'cat2'); // Since patience defaults to 1 and tolerance defaults to 0, early stopping should be triggered expect(earlyStopping.earlyStop).toBe(true); }); it('should not trigger early stopping when seMeasurement does not fall below the threshold', () => { // Update the state with some measurements for cat1, where seMeasurement is above the threshold earlyStopping.update({ cat1: { nItems: 1, seMeasurement: 0.05 } as any }, 'cat1'); // Early stopping should not be triggered because the seMeasurement is above the threshold expect(earlyStopping.earlyStop).toBe(false); }); }); describe('StopIfSEMeasurementBelowThreshold with undefined seMeasurementThreshold for a category', () => { let earlyStopping: StopIfSEMeasurementBelowThreshold; let input: StopIfSEMeasurementBelowThresholdInput; beforeEach(() => { input = { seMeasurementThreshold: {}, // Empty object, meaning no thresholds are defined patience: { cat1: 2 }, // Setting patience to 2 for cat1 tolerance: { cat1: 0.01 }, // Small tolerance for cat1 logicalOperation: 'only', }; earlyStopping = new StopIfSEMeasurementBelowThreshold(input); }); it('should use a default seThreshold of 0 when seMeasurementThreshold is not defined for the category', () => { // Update the state with measurements for cat1, ensuring to meet the patience requirement earlyStopping.update({ cat1: { nItems: 1, seMeasurement: -0.005 } as any }, 'cat1'); earlyStopping.update({ cat1: { nItems: 2, seMeasurement: -0.01 } as any }, 'cat1'); // Early stopping should now be triggered because the seMeasurement has been below the default threshold of 0 for the patience period expect(earlyStopping.earlyStop).toBe(true); }); }); describe('StopOnSEMeasurementPlateau without tolerance provided', () => { let earlyStopping: StopOnSEMeasurementPlateau; let input: StopOnSEMeasurementPlateauInput; beforeEach(() => { input = { patience: { cat1: 2 }, // No tolerance is provided, it should default to an empty object logicalOperation: 'only', }; earlyStopping = new StopOnSEMeasurementPlateau(input); }); it('should handle updates without triggering early stopping when no tolerance is provided', () => { // Update with measurements for cat1 that are not exactly equal, simulating tolerance as undefined earlyStopping.update({ cat1: { nItems: 1, seMeasurement: 0.5 } as any }, 'cat1'); earlyStopping.update({ cat1: { nItems: 2, seMeasurement: 0.55 } as any }, 'cat1'); // Since tolerance is undefined, early stopping should not be triggered even if seMeasurements are slightly different expect(earlyStopping.earlyStop).toBe(false); }); }); describe.each` logicalOperation ${'and'} ${'or'} `("StopIfSEMeasurementBelowThreshold (with logicalOperation='$logicalOperation'", ({ logicalOperation }) => { let earlyStopping: StopIfSEMeasurementBelowThreshold; let input: StopIfSEMeasurementBelowThresholdInput; beforeEach(() => { input = { patience: { cat1: 1, cat2: 3 }, tolerance: { cat1: 0.01, cat2: 0.02 }, seMeasurementThreshold: { cat1: 0.03, cat2: 0.02 }, logicalOperation, }; earlyStopping = new StopIfSEMeasurementBelowThreshold(input); }); it('instantiates with input parameters', () => testInstantiation(earlyStopping, input)); it('validates input', () => testLogicalOperationValidation(StopIfSEMeasurementBelowThreshold, { ...input, logicalOperation: 'invalid' as 'and', })); it('updates internal state when new measurements are added', () => testInternalState(earlyStopping)); it('stops when the seMeasurement has fallen below a threshold', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 0.02, } as Cat, cat2: { nItems: 2, seMeasurement: 0.02, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.02, } as Cat, cat2: { nItems: 3, seMeasurement: 0.02, } as Cat, }, { cat1: { nItems: 4, seMeasurement: 0.02, } as Cat, cat2: { nItems: 4, seMeasurement: 0.02, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'or') { earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(true); } else { earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(true); } }); it('does not stop when the seMeasurement is above threshold', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 0.1, } as Cat, cat2: { nItems: 2, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.1, } as Cat, cat2: { nItems: 3, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 4, seMeasurement: 0.1, } as Cat, cat2: { nItems: 4, seMeasurement: 0.3, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(false); }); it('waits for `patience` items to monitor the seMeasurement plateau', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 0.5, } as Cat, cat2: { nItems: 1, seMeasurement: 0.3, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 0.5, } as Cat, cat2: { nItems: 2, seMeasurement: 0.01, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.5, } as Cat, cat2: { nItems: 3, seMeasurement: 0.01, } as Cat, }, { cat1: { nItems: 4, seMeasurement: 0.5, } as Cat, // Cat2 should trigger when logicalOperation is 'or' cat2: { nItems: 4, seMeasurement: 0.01, } as Cat, }, { // Cat1 should trigger when logicalOperation is 'and' // Cat2 criterion was satisfied after last update cat1: { nItems: 5, seMeasurement: 0.01, } as Cat, cat2: { nItems: 5, seMeasurement: 0.01, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'or') { earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(true); } else { earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[4]); expect(earlyStopping.earlyStop).toBe(true); } }); it('triggers early stopping when within tolerance', () => { const updates: CatMap<Cat>[] = [ { cat1: { nItems: 1, seMeasurement: 10, } as Cat, cat2: { nItems: 1, seMeasurement: 0.4, } as Cat, }, { cat1: { nItems: 2, seMeasurement: 1, } as Cat, cat2: { nItems: 2, seMeasurement: 0.02, } as Cat, }, { cat1: { nItems: 3, seMeasurement: 0.0001, } as Cat, cat2: { nItems: 3, seMeasurement: 0.04, } as Cat, }, { cat1: { nItems: 4, seMeasurement: 0.5, } as Cat, cat2: { nItems: 4, seMeasurement: 0.01, } as Cat, }, ]; earlyStopping.update(updates[0]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[1]); expect(earlyStopping.earlyStop).toBe(false); if (earlyStopping.logicalOperation === 'or') { earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(true); earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(true); } else { earlyStopping.update(updates[2]); expect(earlyStopping.earlyStop).toBe(false); earlyStopping.update(updates[3]); expect(earlyStopping.earlyStop).toBe(false); } }); });