@bdelab/jscat
Version:
A library to support IRT-based computer adaptive testing in JavaScript
849 lines (764 loc) • 23.9 kB
text/typescript
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);
}
});
});