answer-randomizer.js•17.2 kB
/**
* Answer Randomization System
* @module content-generation/answer-randomizer
* @description Secure randomization to prevent pattern gaming in assessments
* @version 5.0.0-alpha
*/
export class AnswerRandomizer {
constructor(config = {}) {
this.config = {
enableRandomization: true,
balancePositions: true,
trackDistribution: true,
cryptographicSeed: true,
minShuffleRounds: 3,
maxShuffleRounds: 7,
...config
};
// Position distribution tracking
this.positionHistory = {
total: 0,
positions: {}
};
// Randomization patterns to avoid
this.avoidancePatterns = [
'consecutive_same_position',
'alphabetical_order',
'reverse_alphabetical',
'predictable_sequence'
];
}
/**
* Randomize entire assessment
* @param {Object} assessment - Assessment package
* @returns {Promise<Object>} Assessment with randomized answers
*/
async randomizeAssessment(assessment) {
console.log('[ANSWER-RANDOMIZER] Randomizing assessment answers');
if (!this.config.enableRandomization) {
console.log('[ANSWER-RANDOMIZER] Randomization disabled, returning unchanged');
return assessment;
}
try {
const randomized = { ...assessment };
// Randomize quiz questions
if (randomized.quiz && randomized.quiz.questions) {
randomized.quiz.questions = await this.randomizeQuestions(randomized.quiz.questions);
}
// Randomize flashcards (if they have options)
if (randomized.flashcards) {
randomized.flashcards = await this.randomizeFlashcards(randomized.flashcards);
}
// Update analytics
if (randomized.analytics) {
randomized.analytics.randomizationApplied = true;
randomized.analytics.randomizationTimestamp = new Date().toISOString();
randomized.analytics.positionDistribution = this.getPositionDistribution();
}
console.log('[ANSWER-RANDOMIZER] Assessment randomization complete');
return randomized;
} catch (error) {
console.error('[ANSWER-RANDOMIZER] Randomization failed:', error);
throw new Error(`Answer randomization failed: ${error.message}`);
}
}
/**
* Randomize quiz questions
* @param {Array} questions - Quiz questions
* @returns {Promise<Array>} Randomized questions
*/
async randomizeQuestions(questions) {
const randomized = [];
for (const question of questions) {
const randomizedQuestion = await this.randomizeQuestion(question);
randomized.push(randomizedQuestion);
}
return randomized;
}
/**
* Randomize individual question
* @param {Object} question - Question object
* @returns {Promise<Object>} Randomized question
*/
async randomizeQuestion(question) {
const randomized = { ...question };
switch (question.type) {
case 'multiple_choice':
return this.randomizeMultipleChoice(randomized);
case 'matching':
return this.randomizeMatching(randomized);
case 'true_false':
// True/false doesn't need answer randomization
return randomized;
case 'fill_blank':
// Fill blank doesn't need answer randomization
return randomized;
case 'short_answer':
// Short answer doesn't need answer randomization
return randomized;
default:
console.warn(`[ANSWER-RANDOMIZER] Unknown question type: ${question.type}`);
return randomized;
}
}
/**
* Randomize multiple choice question
* @param {Object} question - Multiple choice question
* @returns {Object} Randomized question
*/
randomizeMultipleChoice(question) {
if (!question.options || question.options.length < 2) {
return question;
}
const randomized = { ...question };
const correctAnswer = question.options[question.correct];
// Perform secure shuffle
const shuffled = this.secureShuffleArray([...question.options]);
// Find new position of correct answer
const newCorrectIndex = shuffled.indexOf(correctAnswer);
// Update question with randomized data
randomized.options = shuffled;
randomized.correct = newCorrectIndex;
// Add randomization metadata
randomized.randomization = {
originalPositions: question.options.map((option, index) => ({
option: option,
originalIndex: index,
newIndex: shuffled.indexOf(option)
})),
correctAnswerMovement: {
from: question.correct,
to: newCorrectIndex
},
shuffleTimestamp: new Date().toISOString(),
shuffleMethod: 'fisher_yates_secure'
};
// Track position distribution
this.trackCorrectAnswerPosition(newCorrectIndex, question.options.length);
return randomized;
}
/**
* Randomize matching question
* @param {Object} question - Matching question
* @returns {Object} Randomized question
*/
randomizeMatching(question) {
if (!question.leftColumn || !question.rightColumn) {
return question;
}
const randomized = { ...question };
// Shuffle right column only (left column stays as reference)
const originalRightColumn = [...question.rightColumn];
const shuffledRightColumn = this.secureShuffleArray([...question.rightColumn]);
// Update correct pairs mapping
const newCorrectPairs = question.correctPairs.map(pair => ({
left: pair.left, // Left stays the same
right: shuffledRightColumn.indexOf(originalRightColumn[pair.right])
}));
randomized.rightColumn = shuffledRightColumn;
randomized.correctPairs = newCorrectPairs;
// Add randomization metadata
randomized.randomization = {
rightColumnShuffle: originalRightColumn.map((item, index) => ({
item: item,
originalIndex: index,
newIndex: shuffledRightColumn.indexOf(item)
})),
shuffleTimestamp: new Date().toISOString(),
shuffleMethod: 'fisher_yates_secure'
};
return randomized;
}
/**
* Randomize flashcards (if they have multiple choice elements)
* @param {Array} flashcards - Flashcards array
* @returns {Promise<Array>} Randomized flashcards
*/
async randomizeFlashcards(flashcards) {
// Flashcards typically don't need answer randomization
// But we shuffle the order of flashcards themselves
return this.secureShuffleArray([...flashcards]);
}
/**
* Secure array shuffle using Fisher-Yates algorithm
* @param {Array} array - Array to shuffle
* @returns {Array} Shuffled array
*/
secureShuffleArray(array) {
const shuffled = [...array];
const rounds = this.getRandomShuffleRounds();
// Perform multiple shuffle rounds for extra security
for (let round = 0; round < rounds; round++) {
for (let i = shuffled.length - 1; i > 0; i--) {
// Use cryptographically secure random if available
const j = this.getSecureRandomIndex(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
}
// Verify shuffle quality
if (this.isShuffleQualityGood(array, shuffled)) {
return shuffled;
} else {
// Recursively shuffle again if quality is poor
return this.secureShuffleArray(array);
}
}
/**
* Get secure random index
* @param {number} max - Maximum value (exclusive)
* @returns {number} Secure random index
*/
getSecureRandomIndex(max) {
if (this.config.cryptographicSeed && typeof crypto !== 'undefined' && crypto.getRandomValues) {
// Use cryptographically secure random
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return array[0] % max;
} else {
// Fallback to Math.random with enhanced seeding
return Math.floor(this.getEnhancedRandom() * max);
}
}
/**
* Get enhanced random number
* @returns {number} Enhanced random number
*/
getEnhancedRandom() {
// Combine multiple entropy sources
const time = Date.now();
const performance = typeof performance !== 'undefined' ? performance.now() : 0;
const random = Math.random();
// Simple hash function for additional entropy
const seed = (time + performance + random * 1000000) % 1000000;
return (seed * 9301 + 49297) % 233280 / 233280;
}
/**
* Get random number of shuffle rounds
* @returns {number} Number of shuffle rounds
*/
getRandomShuffleRounds() {
const min = this.config.minShuffleRounds;
const max = this.config.maxShuffleRounds;
return min + Math.floor(Math.random() * (max - min + 1));
}
/**
* Check shuffle quality
* @param {Array} original - Original array
* @param {Array} shuffled - Shuffled array
* @returns {boolean} Whether shuffle quality is good
*/
isShuffleQualityGood(original, shuffled) {
if (original.length !== shuffled.length) return false;
if (original.length < 2) return true;
// Check that at least 50% of items changed position
let unchangedCount = 0;
for (let i = 0; i < original.length; i++) {
if (original[i] === shuffled[i]) {
unchangedCount++;
}
}
const unchangedRatio = unchangedCount / original.length;
return unchangedRatio < 0.5; // Less than 50% unchanged is good
}
/**
* Track correct answer position for distribution analysis
* @param {number} position - Position of correct answer
* @param {number} totalOptions - Total number of options
*/
trackCorrectAnswerPosition(position, totalOptions) {
if (!this.config.trackDistribution) return;
this.positionHistory.total++;
if (!this.positionHistory.positions[totalOptions]) {
this.positionHistory.positions[totalOptions] = {};
}
if (!this.positionHistory.positions[totalOptions][position]) {
this.positionHistory.positions[totalOptions][position] = 0;
}
this.positionHistory.positions[totalOptions][position]++;
}
/**
* Get position distribution statistics
* @returns {Object} Position distribution data
*/
getPositionDistribution() {
const distribution = {};
Object.entries(this.positionHistory.positions).forEach(([optionCount, positions]) => {
distribution[optionCount] = {};
const total = Object.values(positions).reduce((sum, count) => sum + count, 0);
Object.entries(positions).forEach(([position, count]) => {
distribution[optionCount][position] = {
count: count,
percentage: total > 0 ? (count / total * 100).toFixed(2) : 0
};
});
});
return {
totalQuestions: this.positionHistory.total,
byOptionCount: distribution,
generatedAt: new Date().toISOString()
};
}
/**
* Analyze randomization patterns for gaming detection
* @param {Array} questions - Questions to analyze
* @returns {Object} Pattern analysis
*/
analyzeRandomizationPatterns(questions) {
const analysis = {
consecutivePatterns: this.detectConsecutivePatterns(questions),
distributionBalance: this.analyzeDistributionBalance(questions),
predictabilityScore: this.calculatePredictabilityScore(questions),
qualityScore: 0
};
// Calculate overall quality score
analysis.qualityScore = this.calculateRandomizationQuality(analysis);
return analysis;
}
/**
* Detect consecutive patterns
* @param {Array} questions - Questions array
* @returns {Object} Pattern detection results
*/
detectConsecutivePatterns(questions) {
const patterns = {
consecutiveSame: 0,
consecutiveAscending: 0,
consecutiveDescending: 0,
maxConsecutive: 0
};
let currentStreak = 1;
let currentPattern = null;
for (let i = 1; i < questions.length; i++) {
const prevCorrect = questions[i - 1].correct;
const currCorrect = questions[i].correct;
if (prevCorrect === currCorrect) {
if (currentPattern === 'same') {
currentStreak++;
} else {
currentPattern = 'same';
currentStreak = 2;
}
patterns.consecutiveSame++;
} else if (currCorrect === prevCorrect + 1) {
if (currentPattern === 'ascending') {
currentStreak++;
} else {
currentPattern = 'ascending';
currentStreak = 2;
}
patterns.consecutiveAscending++;
} else if (currCorrect === prevCorrect - 1) {
if (currentPattern === 'descending') {
currentStreak++;
} else {
currentPattern = 'descending';
currentStreak = 2;
}
patterns.consecutiveDescending++;
} else {
currentStreak = 1;
currentPattern = null;
}
patterns.maxConsecutive = Math.max(patterns.maxConsecutive, currentStreak);
}
return patterns;
}
/**
* Analyze distribution balance
* @param {Array} questions - Questions array
* @returns {Object} Distribution analysis
*/
analyzeDistributionBalance(questions) {
const distribution = {};
let maxOptions = 0;
questions.forEach(question => {
if (question.type === 'multiple_choice' && question.options) {
const optionCount = question.options.length;
maxOptions = Math.max(maxOptions, optionCount);
if (!distribution[optionCount]) {
distribution[optionCount] = {};
}
if (!distribution[optionCount][question.correct]) {
distribution[optionCount][question.correct] = 0;
}
distribution[optionCount][question.correct]++;
}
});
// Calculate balance scores
const balance = {};
Object.entries(distribution).forEach(([optionCount, positions]) => {
const total = Object.values(positions).reduce((sum, count) => sum + count, 0);
const expected = total / optionCount;
const variance = Object.values(positions).reduce((sum, count) =>
sum + Math.pow(count - expected, 2), 0) / optionCount;
balance[optionCount] = {
total: total,
expected: expected,
variance: variance,
balanceScore: Math.max(0, 100 - (variance / expected * 100))
};
});
return balance;
}
/**
* Calculate predictability score
* @param {Array} questions - Questions array
* @returns {number} Predictability score (0-100, lower is better)
*/
calculatePredictabilityScore(questions) {
if (questions.length < 3) return 0;
const correctAnswers = questions
.filter(q => q.type === 'multiple_choice')
.map(q => q.correct);
if (correctAnswers.length < 3) return 0;
// Check for patterns
let patternScore = 0;
// Consecutive same answers
for (let i = 1; i < correctAnswers.length; i++) {
if (correctAnswers[i] === correctAnswers[i - 1]) {
patternScore += 10;
}
}
// Arithmetic sequences
for (let i = 2; i < correctAnswers.length; i++) {
const diff1 = correctAnswers[i - 1] - correctAnswers[i - 2];
const diff2 = correctAnswers[i] - correctAnswers[i - 1];
if (diff1 === diff2 && diff1 !== 0) {
patternScore += 15;
}
}
// Overall uniformity (too even distribution)
const uniqueAnswers = new Set(correctAnswers).size;
const maxPossible = Math.max(...correctAnswers) + 1;
if (uniqueAnswers === maxPossible && correctAnswers.length >= maxPossible * 2) {
patternScore += 20;
}
return Math.min(100, patternScore);
}
/**
* Calculate overall randomization quality
* @param {Object} analysis - Pattern analysis
* @returns {number} Quality score (0-100, higher is better)
*/
calculateRandomizationQuality(analysis) {
let score = 100;
// Deduct for consecutive patterns
score -= Math.min(30, analysis.consecutivePatterns.maxConsecutive * 5);
// Deduct for high predictability
score -= analysis.predictabilityScore * 0.5;
// Add for good distribution balance
const avgBalance = Object.values(analysis.distributionBalance || {})
.reduce((sum, balance) => sum + (balance.balanceScore || 0), 0) /
Object.keys(analysis.distributionBalance || {}).length;
score += (avgBalance || 0) * 0.2;
return Math.max(0, Math.min(100, score));
}
/**
* Reset position tracking
*/
resetPositionTracking() {
this.positionHistory = {
total: 0,
positions: {}
};
}
/**
* Export randomization statistics
* @returns {Object} Statistics export
*/
exportStatistics() {
return {
positionHistory: this.positionHistory,
distribution: this.getPositionDistribution(),
config: this.config,
exportedAt: new Date().toISOString()
};
}
}
// Factory function for creating AnswerRandomizer instances
export function createAnswerRandomizer(config = {}) {
return new AnswerRandomizer(config);
}