@higher.archi/boe 1.0.21 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/types/rule.d.ts +1 -1
- package/dist/core/types/rule.d.ts.map +1 -1
- package/dist/engines/ranking/compiler.d.ts +12 -0
- package/dist/engines/ranking/compiler.d.ts.map +1 -0
- package/dist/engines/ranking/compiler.js +163 -0
- package/dist/engines/ranking/compiler.js.map +1 -0
- package/dist/engines/ranking/engine.d.ts +48 -0
- package/dist/engines/ranking/engine.d.ts.map +1 -0
- package/dist/engines/ranking/engine.js +89 -0
- package/dist/engines/ranking/engine.js.map +1 -0
- package/dist/engines/ranking/index.d.ts +9 -0
- package/dist/engines/ranking/index.d.ts.map +1 -0
- package/dist/engines/ranking/index.js +23 -0
- package/dist/engines/ranking/index.js.map +1 -0
- package/dist/engines/ranking/strategy.d.ts +21 -0
- package/dist/engines/ranking/strategy.d.ts.map +1 -0
- package/dist/engines/ranking/strategy.js +250 -0
- package/dist/engines/ranking/strategy.js.map +1 -0
- package/dist/engines/ranking/types.d.ts +142 -0
- package/dist/engines/ranking/types.d.ts.map +1 -0
- package/dist/engines/ranking/types.js +46 -0
- package/dist/engines/ranking/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types/rule.ts +1 -1
- package/src/engines/ranking/compiler.ts +194 -0
- package/src/engines/ranking/engine.ts +120 -0
- package/src/engines/ranking/index.ts +46 -0
- package/src/engines/ranking/strategy.ts +333 -0
- package/src/engines/ranking/types.ts +231 -0
- package/src/index.ts +36 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking Engine Compiler
|
|
3
|
+
*
|
|
4
|
+
* Validates ranking rulesets and resolves defaults.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CompilationError } from '../../core/errors';
|
|
8
|
+
import { SEMANTIC_PRIORITY_VALUES, isSemanticPriority, type SemanticPriority } from '../utility/types';
|
|
9
|
+
|
|
10
|
+
import type { TierDefinition } from '../scoring/types';
|
|
11
|
+
import type {
|
|
12
|
+
RankingRuleSet,
|
|
13
|
+
CompiledRankingRuleSet,
|
|
14
|
+
CompiledScoreRankingRuleSet,
|
|
15
|
+
CompiledEloRankingRuleSet,
|
|
16
|
+
CompiledHeadToHeadRankingRuleSet,
|
|
17
|
+
CompiledRankingCriterion,
|
|
18
|
+
KFactorPreset
|
|
19
|
+
} from './types';
|
|
20
|
+
import { K_FACTOR_VALUES, isKFactorPreset } from './types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compile and validate a ranking ruleset.
|
|
24
|
+
*/
|
|
25
|
+
export function compileRankingRuleSet<T extends TierDefinition = TierDefinition>(
|
|
26
|
+
ruleSet: RankingRuleSet<T>
|
|
27
|
+
): CompiledRankingRuleSet<T> {
|
|
28
|
+
if (!ruleSet.id) {
|
|
29
|
+
throw new CompilationError('Ranking ruleset requires an id');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ruleSet.mode !== 'ranking') {
|
|
33
|
+
throw new CompilationError(`Expected mode 'ranking', got '${ruleSet.mode}'`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!ruleSet.entityType || ruleSet.entityType.trim() === '') {
|
|
37
|
+
throw new CompilationError('entityType is required and must be non-empty');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
switch (ruleSet.strategy) {
|
|
41
|
+
case 'score':
|
|
42
|
+
return compileScore(ruleSet) as CompiledRankingRuleSet<T>;
|
|
43
|
+
case 'elo':
|
|
44
|
+
return compileElo(ruleSet as any) as CompiledRankingRuleSet<T>;
|
|
45
|
+
case 'head-to-head':
|
|
46
|
+
return compileHeadToHead(ruleSet as any) as CompiledRankingRuleSet<T>;
|
|
47
|
+
default:
|
|
48
|
+
throw new CompilationError(`Unknown ranking strategy: '${(ruleSet as any).strategy}'`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compileScore<T extends TierDefinition>(
|
|
53
|
+
ruleSet: RankingRuleSet<T> & { strategy: 'score' }
|
|
54
|
+
): CompiledScoreRankingRuleSet<T> {
|
|
55
|
+
if (!ruleSet.scoringRuleset) {
|
|
56
|
+
throw new CompilationError('Score strategy requires a scoringRuleset');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (ruleSet.scoringRuleset.mode !== 'scoring') {
|
|
60
|
+
throw new CompilationError(`scoringRuleset must have mode 'scoring', got '${ruleSet.scoringRuleset.mode}'`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Validate tier IDs are unique
|
|
64
|
+
const tiers = ruleSet.config?.tiers;
|
|
65
|
+
if (tiers) {
|
|
66
|
+
const tierIds = new Set<string>();
|
|
67
|
+
for (const tier of tiers) {
|
|
68
|
+
if (tierIds.has(tier.id)) {
|
|
69
|
+
throw new CompilationError(`Duplicate tier id: '${tier.id}'`);
|
|
70
|
+
}
|
|
71
|
+
tierIds.add(tier.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
id: ruleSet.id,
|
|
77
|
+
name: ruleSet.name,
|
|
78
|
+
mode: 'ranking',
|
|
79
|
+
strategy: 'score',
|
|
80
|
+
scoringRuleset: ruleSet.scoringRuleset,
|
|
81
|
+
scoringOptions: ruleSet.scoringOptions,
|
|
82
|
+
entityType: ruleSet.entityType,
|
|
83
|
+
config: {
|
|
84
|
+
direction: ruleSet.config?.direction ?? 'highest-first',
|
|
85
|
+
tiers: ruleSet.config?.tiers
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function compileElo(
|
|
91
|
+
ruleSet: RankingRuleSet & { strategy: 'elo' }
|
|
92
|
+
): CompiledEloRankingRuleSet {
|
|
93
|
+
const config = ruleSet.config ?? {};
|
|
94
|
+
|
|
95
|
+
// Resolve kFactor
|
|
96
|
+
let kFactor: number;
|
|
97
|
+
if (config.kFactor === undefined) {
|
|
98
|
+
kFactor = K_FACTOR_VALUES['standard'];
|
|
99
|
+
} else if (isKFactorPreset(config.kFactor)) {
|
|
100
|
+
kFactor = K_FACTOR_VALUES[config.kFactor as KFactorPreset];
|
|
101
|
+
} else if (typeof config.kFactor === 'number') {
|
|
102
|
+
if (config.kFactor <= 0) {
|
|
103
|
+
throw new CompilationError('kFactor must be a positive number');
|
|
104
|
+
}
|
|
105
|
+
kFactor = config.kFactor;
|
|
106
|
+
} else {
|
|
107
|
+
throw new CompilationError(`Invalid kFactor: '${config.kFactor}'`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const initialRating = config.initialRating ?? 1500;
|
|
111
|
+
if (initialRating <= 0) {
|
|
112
|
+
throw new CompilationError('initialRating must be a positive number');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: ruleSet.id,
|
|
117
|
+
name: ruleSet.name,
|
|
118
|
+
mode: 'ranking',
|
|
119
|
+
strategy: 'elo',
|
|
120
|
+
entityType: ruleSet.entityType,
|
|
121
|
+
matchType: (ruleSet as any).matchType ?? 'MatchResult',
|
|
122
|
+
config: {
|
|
123
|
+
initialRating,
|
|
124
|
+
kFactor,
|
|
125
|
+
direction: config.direction ?? 'highest-first'
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compileHeadToHead(
|
|
131
|
+
ruleSet: RankingRuleSet & { strategy: 'head-to-head' }
|
|
132
|
+
): CompiledHeadToHeadRankingRuleSet {
|
|
133
|
+
const criteria = (ruleSet as any).criteria;
|
|
134
|
+
|
|
135
|
+
if (!criteria || !Array.isArray(criteria) || criteria.length === 0) {
|
|
136
|
+
throw new CompilationError('Head-to-head strategy requires at least one criterion');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate no duplicate criterion IDs
|
|
140
|
+
const criterionIds = new Set<string>();
|
|
141
|
+
for (const c of criteria) {
|
|
142
|
+
if (!c.id) {
|
|
143
|
+
throw new CompilationError('Each criterion requires an id');
|
|
144
|
+
}
|
|
145
|
+
if (criterionIds.has(c.id)) {
|
|
146
|
+
throw new CompilationError(`Duplicate criterion id: '${c.id}'`);
|
|
147
|
+
}
|
|
148
|
+
criterionIds.add(c.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolve semantic weights to numeric
|
|
152
|
+
const resolvedCriteria: CompiledRankingCriterion[] = criteria.map((c: any) => {
|
|
153
|
+
let weight: number;
|
|
154
|
+
if (isSemanticPriority(c.weight)) {
|
|
155
|
+
weight = SEMANTIC_PRIORITY_VALUES[c.weight as SemanticPriority];
|
|
156
|
+
} else if (typeof c.weight === 'number') {
|
|
157
|
+
weight = c.weight;
|
|
158
|
+
} else {
|
|
159
|
+
throw new CompilationError(`Invalid weight for criterion '${c.id}': '${c.weight}'`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!c.direction) {
|
|
163
|
+
throw new CompilationError(`Criterion '${c.id}' requires a direction`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: c.id,
|
|
168
|
+
name: c.name,
|
|
169
|
+
weight,
|
|
170
|
+
direction: c.direction
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Normalize weights to sum to 1.0
|
|
175
|
+
const totalWeight = resolvedCriteria.reduce((sum, c) => sum + c.weight, 0);
|
|
176
|
+
if (totalWeight === 0) {
|
|
177
|
+
throw new CompilationError('Criteria weights must not all be zero');
|
|
178
|
+
}
|
|
179
|
+
for (const c of resolvedCriteria) {
|
|
180
|
+
c.weight = c.weight / totalWeight;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
id: ruleSet.id,
|
|
185
|
+
name: ruleSet.name,
|
|
186
|
+
mode: 'ranking',
|
|
187
|
+
strategy: 'head-to-head',
|
|
188
|
+
entityType: ruleSet.entityType,
|
|
189
|
+
criteria: resolvedCriteria,
|
|
190
|
+
config: {
|
|
191
|
+
direction: (ruleSet as any).config?.direction ?? 'highest-first'
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking Engine
|
|
3
|
+
*
|
|
4
|
+
* Comparative scoring engine that ranks N entities relative to each other.
|
|
5
|
+
* Supports score-based ranking, Elo ratings, and head-to-head comparison.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const engine = new RankingEngine();
|
|
10
|
+
* engine.add({ type: 'Vendor', data: { id: 'v1', revenue: 500000 } });
|
|
11
|
+
* engine.add({ type: 'Vendor', data: { id: 'v2', revenue: 120000 } });
|
|
12
|
+
*
|
|
13
|
+
* const result = engine.execute(compiledRanking);
|
|
14
|
+
* console.log(result.rankings[0]); // { rank: 1, entityId: 'v1', percentileLabel: 'top-1%', ... }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
WorkingMemory,
|
|
20
|
+
Fact,
|
|
21
|
+
FactInput,
|
|
22
|
+
FactChange
|
|
23
|
+
} from '../../core';
|
|
24
|
+
|
|
25
|
+
import type { TierDefinition } from '../scoring/types';
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
CompiledRankingRuleSet,
|
|
29
|
+
RankingOptions,
|
|
30
|
+
RankingResult
|
|
31
|
+
} from './types';
|
|
32
|
+
|
|
33
|
+
import { RankingExecutor } from './strategy';
|
|
34
|
+
|
|
35
|
+
export class RankingEngine {
|
|
36
|
+
private wm: WorkingMemory;
|
|
37
|
+
private strategy: RankingExecutor;
|
|
38
|
+
|
|
39
|
+
constructor(workingMemory?: WorkingMemory) {
|
|
40
|
+
this.wm = workingMemory ?? new WorkingMemory();
|
|
41
|
+
this.strategy = new RankingExecutor();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ========================================
|
|
45
|
+
// IWorkingMemory Implementation
|
|
46
|
+
// ========================================
|
|
47
|
+
|
|
48
|
+
add<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
49
|
+
return this.wm.add(input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
remove(factId: string): Fact | undefined {
|
|
53
|
+
return this.wm.remove(factId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
update<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
57
|
+
return this.wm.update(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get(factId: string): Fact | undefined {
|
|
61
|
+
return this.wm.get(factId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getByType(type: string): Fact[] {
|
|
65
|
+
return this.wm.getByType(type);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getAll(): Fact[] {
|
|
69
|
+
return this.wm.getAll();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
has(factId: string): boolean {
|
|
73
|
+
return this.wm.has(factId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
size(): number {
|
|
77
|
+
return this.wm.size();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clear(): void {
|
|
81
|
+
this.wm.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getChanges(): FactChange[] {
|
|
85
|
+
return this.wm.getChanges();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
clearChanges(): void {
|
|
89
|
+
this.wm.clearChanges();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ========================================
|
|
93
|
+
// Engine Execution
|
|
94
|
+
// ========================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Execute a ranking ruleset.
|
|
98
|
+
*
|
|
99
|
+
* Scores all entities of the configured type and produces
|
|
100
|
+
* a ranked list with percentiles and optional tier/movement tracking.
|
|
101
|
+
*
|
|
102
|
+
* @param ruleSet - Compiled ranking ruleset
|
|
103
|
+
* @param options - Runtime options (previousRankings for movement, onRank callback)
|
|
104
|
+
* @returns Ranking result with sorted entities
|
|
105
|
+
*/
|
|
106
|
+
execute<T extends TierDefinition = TierDefinition>(
|
|
107
|
+
ruleSet: CompiledRankingRuleSet<T>,
|
|
108
|
+
options: RankingOptions = {}
|
|
109
|
+
): RankingResult<T> {
|
|
110
|
+
return this.strategy.run(ruleSet, this.wm, options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ========================================
|
|
114
|
+
// Utility Methods
|
|
115
|
+
// ========================================
|
|
116
|
+
|
|
117
|
+
getWorkingMemory(): WorkingMemory {
|
|
118
|
+
return this.wm;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking Engine — Comparative Entity Ranking
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
export type {
|
|
7
|
+
RankingStrategy,
|
|
8
|
+
RankingDirection,
|
|
9
|
+
PercentileLabel,
|
|
10
|
+
Movement,
|
|
11
|
+
KFactorPreset,
|
|
12
|
+
RankingCriterion,
|
|
13
|
+
CompiledRankingCriterion,
|
|
14
|
+
ScoreRankingConfig,
|
|
15
|
+
EloConfig,
|
|
16
|
+
HeadToHeadConfig,
|
|
17
|
+
ScoreRankingRuleSet,
|
|
18
|
+
EloRankingRuleSet,
|
|
19
|
+
HeadToHeadRankingRuleSet,
|
|
20
|
+
RankingRuleSet,
|
|
21
|
+
CompiledScoreRankingRuleSet,
|
|
22
|
+
CompiledEloRankingRuleSet,
|
|
23
|
+
CompiledHeadToHeadRankingRuleSet,
|
|
24
|
+
CompiledRankingRuleSet,
|
|
25
|
+
PreviousRanking,
|
|
26
|
+
RankingOptions,
|
|
27
|
+
RankedEntity,
|
|
28
|
+
RankingResult
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
// Constants & utilities
|
|
32
|
+
export {
|
|
33
|
+
K_FACTOR_VALUES,
|
|
34
|
+
isKFactorPreset,
|
|
35
|
+
resolvePercentileLabel,
|
|
36
|
+
resolveMovement
|
|
37
|
+
} from './types';
|
|
38
|
+
|
|
39
|
+
// Compiler
|
|
40
|
+
export { compileRankingRuleSet } from './compiler';
|
|
41
|
+
|
|
42
|
+
// Strategy
|
|
43
|
+
export { RankingExecutor, rankingStrategy } from './strategy';
|
|
44
|
+
|
|
45
|
+
// Engine
|
|
46
|
+
export { RankingEngine } from './engine';
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking Engine Strategy
|
|
3
|
+
*
|
|
4
|
+
* Core execution logic for all ranking strategies:
|
|
5
|
+
* - score: Delegates to ScoringEngine, ranks by totalScore
|
|
6
|
+
* - elo: Processes match history, computes Elo ratings
|
|
7
|
+
* - head-to-head: Pairwise comparison on weighted criteria
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IWorkingMemory, Fact } from '../../core';
|
|
11
|
+
import { ScoringEngine } from '../scoring/engine';
|
|
12
|
+
import type { TierDefinition, ScoringTierMatch } from '../scoring/types';
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
CompiledRankingRuleSet,
|
|
16
|
+
CompiledScoreRankingRuleSet,
|
|
17
|
+
CompiledEloRankingRuleSet,
|
|
18
|
+
CompiledHeadToHeadRankingRuleSet,
|
|
19
|
+
RankingOptions,
|
|
20
|
+
RankingResult,
|
|
21
|
+
RankedEntity,
|
|
22
|
+
RankingDirection,
|
|
23
|
+
Movement
|
|
24
|
+
} from './types';
|
|
25
|
+
import { resolvePercentileLabel, resolveMovement } from './types';
|
|
26
|
+
|
|
27
|
+
export class RankingExecutor {
|
|
28
|
+
run<T extends TierDefinition = TierDefinition>(
|
|
29
|
+
ruleSet: CompiledRankingRuleSet<T>,
|
|
30
|
+
wm: IWorkingMemory,
|
|
31
|
+
options: RankingOptions = {}
|
|
32
|
+
): RankingResult<T> {
|
|
33
|
+
const startTime = performance.now();
|
|
34
|
+
|
|
35
|
+
let rankings: RankedEntity<T>[];
|
|
36
|
+
|
|
37
|
+
switch (ruleSet.strategy) {
|
|
38
|
+
case 'score':
|
|
39
|
+
rankings = this.runScore(ruleSet as CompiledScoreRankingRuleSet<T>, wm, options);
|
|
40
|
+
break;
|
|
41
|
+
case 'elo':
|
|
42
|
+
rankings = this.runElo(ruleSet as unknown as CompiledEloRankingRuleSet, wm, options) as RankedEntity<T>[];
|
|
43
|
+
break;
|
|
44
|
+
case 'head-to-head':
|
|
45
|
+
rankings = this.runHeadToHead(ruleSet as unknown as CompiledHeadToHeadRankingRuleSet, wm, options) as RankedEntity<T>[];
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`Unknown ranking strategy: '${(ruleSet as any).strategy}'`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const executionTimeMs = Math.round((performance.now() - startTime) * 100) / 100;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
rankings,
|
|
55
|
+
totalEntities: rankings.length,
|
|
56
|
+
strategy: ruleSet.strategy,
|
|
57
|
+
executionTimeMs
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ========================================
|
|
62
|
+
// Score Strategy
|
|
63
|
+
// ========================================
|
|
64
|
+
|
|
65
|
+
private runScore<T extends TierDefinition>(
|
|
66
|
+
ruleSet: CompiledScoreRankingRuleSet<T>,
|
|
67
|
+
wm: IWorkingMemory,
|
|
68
|
+
options: RankingOptions
|
|
69
|
+
): RankedEntity<T>[] {
|
|
70
|
+
const entityFacts = wm.getByType(ruleSet.entityType);
|
|
71
|
+
if (entityFacts.length === 0) return [];
|
|
72
|
+
|
|
73
|
+
// Collect context facts (everything that isn't the entity type)
|
|
74
|
+
const allFacts = wm.getAll();
|
|
75
|
+
const contextFacts = allFacts.filter(f => f.type !== ruleSet.entityType);
|
|
76
|
+
|
|
77
|
+
// Score each entity independently
|
|
78
|
+
const scored: { entityId: string; score: number }[] = [];
|
|
79
|
+
for (const entity of entityFacts) {
|
|
80
|
+
const engine = new ScoringEngine();
|
|
81
|
+
|
|
82
|
+
// Add context facts first
|
|
83
|
+
for (const ctx of contextFacts) {
|
|
84
|
+
engine.add({ type: ctx.type, data: ctx.data, id: ctx.id });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Add this entity
|
|
88
|
+
engine.add({ type: entity.type, data: entity.data, id: entity.id });
|
|
89
|
+
|
|
90
|
+
const result = engine.execute(ruleSet.scoringRuleset, ruleSet.scoringOptions);
|
|
91
|
+
const entityId = resolveEntityId(entity);
|
|
92
|
+
|
|
93
|
+
scored.push({ entityId, score: result.totalScore });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Sort by score
|
|
97
|
+
sortByScore(scored, ruleSet.config.direction);
|
|
98
|
+
|
|
99
|
+
// Build ranked entities
|
|
100
|
+
return this.buildRankedEntities(scored, entityFacts.length, options, ruleSet.config.tiers);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ========================================
|
|
104
|
+
// Elo Strategy
|
|
105
|
+
// ========================================
|
|
106
|
+
|
|
107
|
+
private runElo(
|
|
108
|
+
ruleSet: CompiledEloRankingRuleSet,
|
|
109
|
+
wm: IWorkingMemory,
|
|
110
|
+
options: RankingOptions
|
|
111
|
+
): RankedEntity[] {
|
|
112
|
+
const entityFacts = wm.getByType(ruleSet.entityType);
|
|
113
|
+
if (entityFacts.length === 0) return [];
|
|
114
|
+
|
|
115
|
+
const { initialRating, kFactor } = ruleSet.config;
|
|
116
|
+
|
|
117
|
+
// Initialize ratings
|
|
118
|
+
const ratings = new Map<string, number>();
|
|
119
|
+
for (const entity of entityFacts) {
|
|
120
|
+
ratings.set(resolveEntityId(entity), initialRating);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Process match results in insertion order
|
|
124
|
+
const matches = wm.getByType(ruleSet.matchType);
|
|
125
|
+
for (const match of matches) {
|
|
126
|
+
const { winnerId, loserId, draw } = match.data;
|
|
127
|
+
|
|
128
|
+
const ratingA = ratings.get(winnerId);
|
|
129
|
+
const ratingB = ratings.get(loserId);
|
|
130
|
+
|
|
131
|
+
// Skip matches with unknown entities
|
|
132
|
+
if (ratingA === undefined || ratingB === undefined) continue;
|
|
133
|
+
|
|
134
|
+
// Expected scores
|
|
135
|
+
const expectedA = 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
|
|
136
|
+
const expectedB = 1 - expectedA;
|
|
137
|
+
|
|
138
|
+
// Actual scores
|
|
139
|
+
const actualA = draw ? 0.5 : 1;
|
|
140
|
+
const actualB = draw ? 0.5 : 0;
|
|
141
|
+
|
|
142
|
+
// Update ratings
|
|
143
|
+
ratings.set(winnerId, ratingA + kFactor * (actualA - expectedA));
|
|
144
|
+
ratings.set(loserId, ratingB + kFactor * (actualB - expectedB));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Convert to scored array
|
|
148
|
+
const scored: { entityId: string; score: number }[] = [];
|
|
149
|
+
for (const [entityId, rating] of ratings) {
|
|
150
|
+
scored.push({ entityId, score: Math.round(rating * 100) / 100 });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
sortByScore(scored, ruleSet.config.direction);
|
|
154
|
+
|
|
155
|
+
return this.buildRankedEntities(scored, entityFacts.length, options);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ========================================
|
|
159
|
+
// Head-to-Head Strategy
|
|
160
|
+
// ========================================
|
|
161
|
+
|
|
162
|
+
private runHeadToHead(
|
|
163
|
+
ruleSet: CompiledHeadToHeadRankingRuleSet,
|
|
164
|
+
wm: IWorkingMemory,
|
|
165
|
+
options: RankingOptions
|
|
166
|
+
): RankedEntity[] {
|
|
167
|
+
const entityFacts = wm.getByType(ruleSet.entityType);
|
|
168
|
+
if (entityFacts.length === 0) return [];
|
|
169
|
+
|
|
170
|
+
const { criteria } = ruleSet;
|
|
171
|
+
|
|
172
|
+
// Extract criterion values per entity
|
|
173
|
+
const entities: { entityId: string; values: Record<string, number> }[] = [];
|
|
174
|
+
for (const entity of entityFacts) {
|
|
175
|
+
const values: Record<string, number> = {};
|
|
176
|
+
for (const c of criteria) {
|
|
177
|
+
values[c.id] = typeof entity.data[c.id] === 'number' ? entity.data[c.id] : 0;
|
|
178
|
+
}
|
|
179
|
+
entities.push({ entityId: resolveEntityId(entity), values });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Round-robin pairwise comparison
|
|
183
|
+
const scores = new Map<string, number>();
|
|
184
|
+
for (const e of entities) {
|
|
185
|
+
scores.set(e.entityId, 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < entities.length; i++) {
|
|
189
|
+
for (let j = i + 1; j < entities.length; j++) {
|
|
190
|
+
const a = entities[i];
|
|
191
|
+
const b = entities[j];
|
|
192
|
+
|
|
193
|
+
for (const c of criteria) {
|
|
194
|
+
const valA = a.values[c.id];
|
|
195
|
+
const valB = b.values[c.id];
|
|
196
|
+
|
|
197
|
+
const betterIsHigher = c.direction === 'highest-first';
|
|
198
|
+
|
|
199
|
+
if (valA === valB) {
|
|
200
|
+
// Tie: split the weight
|
|
201
|
+
scores.set(a.entityId, scores.get(a.entityId)! + c.weight / 2);
|
|
202
|
+
scores.set(b.entityId, scores.get(b.entityId)! + c.weight / 2);
|
|
203
|
+
} else if ((betterIsHigher && valA > valB) || (!betterIsHigher && valA < valB)) {
|
|
204
|
+
scores.set(a.entityId, scores.get(a.entityId)! + c.weight);
|
|
205
|
+
} else {
|
|
206
|
+
scores.set(b.entityId, scores.get(b.entityId)! + c.weight);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Convert to scored array
|
|
213
|
+
const scored: { entityId: string; score: number }[] = [];
|
|
214
|
+
for (const [entityId, score] of scores) {
|
|
215
|
+
scored.push({ entityId, score: Math.round(score * 1000) / 1000 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
sortByScore(scored, ruleSet.config.direction);
|
|
219
|
+
|
|
220
|
+
return this.buildRankedEntities(scored, entityFacts.length, options);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ========================================
|
|
224
|
+
// Shared Helpers
|
|
225
|
+
// ========================================
|
|
226
|
+
|
|
227
|
+
private buildRankedEntities<T extends TierDefinition>(
|
|
228
|
+
scored: { entityId: string; score: number }[],
|
|
229
|
+
totalEntities: number,
|
|
230
|
+
options: RankingOptions,
|
|
231
|
+
tiers?: T[]
|
|
232
|
+
): RankedEntity<T>[] {
|
|
233
|
+
// Build previous ranking lookup
|
|
234
|
+
const previousMap = new Map<string, number>();
|
|
235
|
+
if (options.previousRankings) {
|
|
236
|
+
for (const prev of options.previousRankings) {
|
|
237
|
+
previousMap.set(prev.entityId, prev.rank);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Prepare sorted tiers for matching
|
|
242
|
+
const sortedTiers = tiers
|
|
243
|
+
? [...tiers].sort((a, b) => {
|
|
244
|
+
const ta = a.threshold === '-Infinity' ? Number.NEGATIVE_INFINITY : a.threshold as number;
|
|
245
|
+
const tb = b.threshold === '-Infinity' ? Number.NEGATIVE_INFINITY : b.threshold as number;
|
|
246
|
+
return tb - ta;
|
|
247
|
+
})
|
|
248
|
+
: undefined;
|
|
249
|
+
|
|
250
|
+
// Assign ranks (competition ranking: ties get same rank, next skips)
|
|
251
|
+
const rankings: RankedEntity<T>[] = [];
|
|
252
|
+
let currentRank = 1;
|
|
253
|
+
|
|
254
|
+
for (let i = 0; i < scored.length; i++) {
|
|
255
|
+
// If this entity has the same score as the previous, use the same rank
|
|
256
|
+
if (i > 0 && scored[i].score === scored[i - 1].score) {
|
|
257
|
+
// Same rank as previous
|
|
258
|
+
} else {
|
|
259
|
+
currentRank = i + 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const percentile = totalEntities <= 1
|
|
263
|
+
? 100
|
|
264
|
+
: ((totalEntities - currentRank) / (totalEntities - 1)) * 100;
|
|
265
|
+
|
|
266
|
+
const percentileLabel = resolvePercentileLabel(percentile);
|
|
267
|
+
|
|
268
|
+
// Match tier
|
|
269
|
+
let tier: ScoringTierMatch<T> | undefined;
|
|
270
|
+
if (sortedTiers) {
|
|
271
|
+
for (const tierDef of sortedTiers) {
|
|
272
|
+
const threshold = tierDef.threshold === '-Infinity'
|
|
273
|
+
? Number.NEGATIVE_INFINITY
|
|
274
|
+
: tierDef.threshold as number;
|
|
275
|
+
if (scored[i].score >= threshold) {
|
|
276
|
+
tier = { ...tierDef, threshold } as ScoringTierMatch<T>;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Movement
|
|
283
|
+
let movement: Movement | undefined;
|
|
284
|
+
let delta: number | undefined;
|
|
285
|
+
const previousRank = previousMap.get(scored[i].entityId);
|
|
286
|
+
if (previousRank !== undefined) {
|
|
287
|
+
movement = resolveMovement(previousRank, currentRank);
|
|
288
|
+
delta = previousRank - currentRank; // positive = climbed
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const entity: RankedEntity<T> = {
|
|
292
|
+
entityId: scored[i].entityId,
|
|
293
|
+
rank: currentRank,
|
|
294
|
+
score: scored[i].score,
|
|
295
|
+
percentile: Math.round(percentile * 100) / 100,
|
|
296
|
+
percentileLabel,
|
|
297
|
+
...(tier !== undefined && { tier }),
|
|
298
|
+
...(movement !== undefined && { movement }),
|
|
299
|
+
...(delta !== undefined && { delta })
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (options.onRank) {
|
|
303
|
+
options.onRank(entity);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
rankings.push(entity);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return rankings;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ========================================
|
|
314
|
+
// Module-Level Helpers
|
|
315
|
+
// ========================================
|
|
316
|
+
|
|
317
|
+
function resolveEntityId(fact: Fact): string {
|
|
318
|
+
return fact.data?.id ?? fact.id;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function sortByScore(
|
|
322
|
+
scored: { entityId: string; score: number }[],
|
|
323
|
+
direction: RankingDirection
|
|
324
|
+
): void {
|
|
325
|
+
if (direction === 'highest-first') {
|
|
326
|
+
scored.sort((a, b) => b.score - a.score);
|
|
327
|
+
} else {
|
|
328
|
+
scored.sort((a, b) => a.score - b.score);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Singleton instance */
|
|
333
|
+
export const rankingStrategy = new RankingExecutor();
|