@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.
Files changed (34) hide show
  1. package/dist/core/types/rule.d.ts +1 -1
  2. package/dist/core/types/rule.d.ts.map +1 -1
  3. package/dist/engines/ranking/compiler.d.ts +12 -0
  4. package/dist/engines/ranking/compiler.d.ts.map +1 -0
  5. package/dist/engines/ranking/compiler.js +163 -0
  6. package/dist/engines/ranking/compiler.js.map +1 -0
  7. package/dist/engines/ranking/engine.d.ts +48 -0
  8. package/dist/engines/ranking/engine.d.ts.map +1 -0
  9. package/dist/engines/ranking/engine.js +89 -0
  10. package/dist/engines/ranking/engine.js.map +1 -0
  11. package/dist/engines/ranking/index.d.ts +9 -0
  12. package/dist/engines/ranking/index.d.ts.map +1 -0
  13. package/dist/engines/ranking/index.js +23 -0
  14. package/dist/engines/ranking/index.js.map +1 -0
  15. package/dist/engines/ranking/strategy.d.ts +21 -0
  16. package/dist/engines/ranking/strategy.d.ts.map +1 -0
  17. package/dist/engines/ranking/strategy.js +250 -0
  18. package/dist/engines/ranking/strategy.js.map +1 -0
  19. package/dist/engines/ranking/types.d.ts +142 -0
  20. package/dist/engines/ranking/types.d.ts.map +1 -0
  21. package/dist/engines/ranking/types.js +46 -0
  22. package/dist/engines/ranking/types.js.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +12 -1
  26. package/dist/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/core/types/rule.ts +1 -1
  29. package/src/engines/ranking/compiler.ts +194 -0
  30. package/src/engines/ranking/engine.ts +120 -0
  31. package/src/engines/ranking/index.ts +46 -0
  32. package/src/engines/ranking/strategy.ts +333 -0
  33. package/src/engines/ranking/types.ts +231 -0
  34. 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();