Files
gridpilot.gg/core/identity/domain/services/EligibilityEvaluator.ts
2025-12-29 22:27:33 +01:00

300 lines
9.3 KiB
TypeScript

/**
* Service: EligibilityEvaluator
*
* Pure domain service for DSL-based eligibility evaluation.
* Parses DSL expressions and evaluates them against rating data.
* Provides explainable results with detailed reasons.
*/
import { EvaluationResultDto, EvaluationReason } from '../../application/dtos/EvaluationResultDto';
import { EligibilityFilterDto, ParsedEligibilityFilter, EligibilityCondition } from '../../application/dtos/EligibilityFilterDto';
export interface RatingData {
platform: {
[dimension: string]: number;
};
external: {
[game: string]: {
[type: string]: number;
};
};
}
export class EligibilityEvaluator {
/**
* Main entry point: evaluate DSL against rating data
*/
evaluate(filter: EligibilityFilterDto, ratingData: RatingData): EvaluationResultDto {
try {
const parsed = this.parseDSL(filter.dsl);
const reasons: EvaluationReason[] = [];
// Evaluate each condition
for (const condition of parsed.conditions) {
const reason = this.evaluateCondition(condition, ratingData);
reasons.push(reason);
}
// Determine overall eligibility based on logical operator
const eligible = parsed.logicalOperator === 'AND'
? reasons.every(r => !r.failed)
: reasons.some(r => !r.failed);
// Build summary
const summary = this.buildSummary(eligible, reasons, parsed.logicalOperator);
const metadata: Record<string, unknown> = {
filter: filter.dsl,
};
if (filter.context?.userId) {
metadata.userId = filter.context.userId;
}
return {
eligible,
reasons,
summary,
evaluatedAt: new Date().toISOString(),
metadata,
};
} catch (error) {
// Handle parsing errors
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
const metadata: Record<string, unknown> = {
filter: filter.dsl,
error: errorMessage,
};
if (filter.context?.userId) {
metadata.userId = filter.context.userId;
}
return {
eligible: false,
reasons: [],
summary: `Failed to evaluate filter: ${errorMessage}`,
evaluatedAt: new Date().toISOString(),
metadata,
};
}
}
/**
* Parse DSL string into structured conditions
* Supports: platform.{dim} >= 55 OR external.{game}.{type} between 2000 2500
*/
parseDSL(dsl: string): ParsedEligibilityFilter {
// Normalize and tokenize
const normalized = dsl.trim().replace(/\s+/g, ' ');
// Determine logical operator
const hasOR = normalized.toUpperCase().includes(' OR ');
const hasAND = normalized.toUpperCase().includes(' AND ');
if (hasOR && hasAND) {
throw new Error('Mixed AND/OR not supported. Use parentheses or separate filters.');
}
const logicalOperator = hasOR ? 'OR' : 'AND';
const separator = hasOR ? ' OR ' : ' AND ';
// Split into individual conditions
const conditionStrings = normalized.split(separator).map(s => s.trim());
const conditions: EligibilityCondition[] = conditionStrings.map(str => {
return this.parseCondition(str);
});
return {
conditions,
logicalOperator,
};
}
/**
* Parse a single condition string
* Examples:
* - "platform.driving >= 55"
* - "external.iracing.iRating between 2000 2500"
*/
parseCondition(conditionStr: string): EligibilityCondition {
// Check for "between" operator
const betweenMatch = conditionStr.match(/^(.+?)\s+between\s+(\d+)\s+(\d+)$/i);
if (betweenMatch) {
const targetExpr = betweenMatch[1]?.trim();
const minStr = betweenMatch[2];
const maxStr = betweenMatch[3];
if (!targetExpr || !minStr || !maxStr) {
throw new Error(`Invalid between condition: "${conditionStr}"`);
}
const parsed = this.parseTargetExpression(targetExpr);
return {
target: parsed.target,
dimension: parsed.dimension,
game: parsed.game,
operator: 'between',
expected: [parseFloat(minStr), parseFloat(maxStr)],
} as unknown as EligibilityCondition;
}
// Check for comparison operators
const compareMatch = conditionStr.match(/^(.+?)\s*(>=|<=|>|<|=|!=)\s*(\d+(?:\.\d+)?)$/);
if (compareMatch) {
const targetExpr = compareMatch[1]?.trim();
const operator = compareMatch[2];
const valueStr = compareMatch[3];
if (!targetExpr || !operator || !valueStr) {
throw new Error(`Invalid comparison condition: "${conditionStr}"`);
}
const parsed = this.parseTargetExpression(targetExpr);
return {
target: parsed.target,
dimension: parsed.dimension,
game: parsed.game,
operator: operator as EligibilityCondition['operator'],
expected: parseFloat(valueStr),
} as unknown as EligibilityCondition;
}
throw new Error(`Invalid condition format: "${conditionStr}"`);
}
/**
* Parse target expression like "platform.driving" or "external.iracing.iRating"
*/
private parseTargetExpression(expr: string): { target: 'platform' | 'external'; dimension?: string; game?: string } {
const parts = expr.split('.');
if (parts[0] === 'platform') {
if (parts.length !== 2) {
throw new Error(`Invalid platform expression: "${expr}"`);
}
const dimension = parts[1];
if (!dimension) {
throw new Error(`Invalid platform expression: "${expr}"`);
}
return { target: 'platform', dimension };
}
if (parts[0] === 'external') {
if (parts.length !== 3) {
throw new Error(`Invalid external expression: "${expr}"`);
}
const game = parts[1];
const dimension = parts[2];
if (!game || !dimension) {
throw new Error(`Invalid external expression: "${expr}"`);
}
return { target: 'external', game, dimension };
}
throw new Error(`Unknown target: "${parts[0]}"`);
}
/**
* Evaluate a single condition against rating data
*/
private evaluateCondition(condition: EligibilityCondition, ratingData: RatingData): EvaluationReason {
// Get actual value
let actual: number | undefined;
if (condition.target === 'platform' && condition.dimension) {
actual = ratingData.platform[condition.dimension];
} else if (condition.target === 'external' && condition.game && condition.dimension) {
actual = ratingData.external[condition.game]?.[condition.dimension];
}
// Handle missing data
if (actual === undefined || actual === null || isNaN(actual)) {
return {
target: condition.target === 'platform'
? `platform.${condition.dimension}`
: `external.${condition.game}.${condition.dimension}`,
operator: condition.operator,
expected: condition.expected,
actual: 0,
failed: true,
message: `Missing data for ${condition.target === 'platform' ? `platform dimension "${condition.dimension}"` : `external game "${condition.game}" type "${condition.dimension}"`}`,
};
}
// Evaluate based on operator
let failed = false;
switch (condition.operator) {
case '>=':
failed = actual < (condition.expected as number);
break;
case '<=':
failed = actual > (condition.expected as number);
break;
case '>':
failed = actual <= (condition.expected as number);
break;
case '<':
failed = actual >= (condition.expected as number);
break;
case '=':
failed = actual !== (condition.expected as number);
break;
case '!=':
failed = actual === (condition.expected as number);
break;
case 'between': {
const [min, max] = condition.expected as [number, number];
failed = actual < min || actual > max;
break;
}
default:
throw new Error(`Unknown operator: ${condition.operator}`);
}
const targetStr = condition.target === 'platform'
? `platform.${condition.dimension}`
: `external.${condition.game}.${condition.dimension}`;
const expectedStr = condition.operator === 'between'
? `${(condition.expected as [number, number])[0]} to ${(condition.expected as [number, number])[1]}`
: `${condition.operator} ${condition.expected}`;
return {
target: targetStr,
operator: condition.operator,
expected: condition.expected,
actual,
failed,
message: failed
? `Expected ${targetStr} ${expectedStr}, but got ${actual}`
: `${targetStr} ${expectedStr}`,
};
}
/**
* Build human-readable summary
*/
private buildSummary(eligible: boolean, reasons: EvaluationReason[], operator: 'AND' | 'OR'): string {
const failedReasons = reasons.filter(r => r.failed);
if (eligible) {
if (operator === 'OR') {
return `Eligible: At least one condition met (${reasons.filter(r => !r.failed).length}/${reasons.length})`;
}
return `Eligible: All conditions met (${reasons.length}/${reasons.length})`;
}
if (operator === 'AND') {
return `Not eligible: ${failedReasons.length} condition(s) failed`;
}
return `Not eligible: All conditions failed (${failedReasons.length}/${reasons.length})`;
}
}