@higher.archi/boe 1.0.17 → 1.0.19
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/ensemble/compiler.d.ts +13 -0
- package/dist/engines/ensemble/compiler.d.ts.map +1 -0
- package/dist/engines/ensemble/compiler.js +70 -0
- package/dist/engines/ensemble/compiler.js.map +1 -0
- package/dist/engines/ensemble/engine.d.ts +56 -0
- package/dist/engines/ensemble/engine.d.ts.map +1 -0
- package/dist/engines/ensemble/engine.js +99 -0
- package/dist/engines/ensemble/engine.js.map +1 -0
- package/dist/engines/ensemble/index.d.ts +9 -0
- package/dist/engines/ensemble/index.d.ts.map +1 -0
- package/dist/engines/ensemble/index.js +20 -0
- package/dist/engines/ensemble/index.js.map +1 -0
- package/dist/engines/ensemble/strategy.d.ts +14 -0
- package/dist/engines/ensemble/strategy.d.ts.map +1 -0
- package/dist/engines/ensemble/strategy.js +222 -0
- package/dist/engines/ensemble/strategy.js.map +1 -0
- package/dist/engines/ensemble/summary.d.ts +21 -0
- package/dist/engines/ensemble/summary.d.ts.map +1 -0
- package/dist/engines/ensemble/summary.js +50 -0
- package/dist/engines/ensemble/summary.js.map +1 -0
- package/dist/engines/ensemble/types.d.ts +89 -0
- package/dist/engines/ensemble/types.d.ts.map +1 -0
- package/dist/engines/ensemble/types.js +9 -0
- package/dist/engines/ensemble/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/promotion/index.d.ts +2 -2
- package/dist/promotion/index.d.ts.map +1 -1
- package/dist/promotion/index.js +3 -1
- package/dist/promotion/index.js.map +1 -1
- package/dist/promotion/promotion.d.ts +33 -2
- package/dist/promotion/promotion.d.ts.map +1 -1
- package/dist/promotion/promotion.js +97 -1
- package/dist/promotion/promotion.js.map +1 -1
- package/dist/promotion/types.d.ts +8 -0
- package/dist/promotion/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/types/rule.ts +1 -1
- package/src/engines/ensemble/compiler.ts +97 -0
- package/src/engines/ensemble/engine.ts +130 -0
- package/src/engines/ensemble/index.ts +34 -0
- package/src/engines/ensemble/strategy.ts +263 -0
- package/src/engines/ensemble/summary.ts +58 -0
- package/src/engines/ensemble/types.ts +137 -0
- package/src/index.ts +26 -0
- package/src/promotion/index.ts +2 -2
- package/src/promotion/promotion.ts +115 -2
- package/src/promotion/types.ts +10 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble Engine Compiler
|
|
3
|
+
*
|
|
4
|
+
* Validates and compiles an EnsembleRuleSet into a CompiledEnsembleRuleSet.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CompilationError } from '../../core';
|
|
8
|
+
|
|
9
|
+
import type { TierDefinition } from '../scoring/types';
|
|
10
|
+
import type {
|
|
11
|
+
EnsembleRuleSet,
|
|
12
|
+
CompiledEnsembleRuleSet,
|
|
13
|
+
CompiledEnsembleMember
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compile an ensemble ruleset — validates members, weights, and config,
|
|
18
|
+
* then tags each member with its type for runtime dispatch.
|
|
19
|
+
*/
|
|
20
|
+
export function compileEnsembleRuleSet<T extends TierDefinition = TierDefinition>(
|
|
21
|
+
ruleSet: EnsembleRuleSet<T>
|
|
22
|
+
): CompiledEnsembleRuleSet<T> {
|
|
23
|
+
const { members, config } = ruleSet;
|
|
24
|
+
|
|
25
|
+
// Members array must be non-empty
|
|
26
|
+
if (!members || members.length === 0) {
|
|
27
|
+
throw new CompilationError(
|
|
28
|
+
`Ensemble "${ruleSet.id}": must have at least one member`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// No duplicate member IDs
|
|
33
|
+
const ids = new Set<string>();
|
|
34
|
+
for (const member of members) {
|
|
35
|
+
if (ids.has(member.id)) {
|
|
36
|
+
throw new CompilationError(
|
|
37
|
+
`Ensemble "${ruleSet.id}": duplicate member id "${member.id}"`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
ids.add(member.id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// All weights must be > 0
|
|
44
|
+
for (const member of members) {
|
|
45
|
+
if (member.weight <= 0) {
|
|
46
|
+
throw new CompilationError(
|
|
47
|
+
`Ensemble "${ruleSet.id}": member "${member.id}" weight must be > 0 (got ${member.weight})`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// For weighted-average and voting: weights must sum to ~1.0
|
|
53
|
+
if (config.strategy === 'weighted-average' || config.strategy === 'voting') {
|
|
54
|
+
const total = members.reduce((sum, m) => sum + m.weight, 0);
|
|
55
|
+
if (Math.abs(total - 1.0) > 0.001) {
|
|
56
|
+
throw new CompilationError(
|
|
57
|
+
`Ensemble "${ruleSet.id}": member weights must sum to 1.0 for strategy "${config.strategy}" (got ${total})`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// For stacking: config.stacking must be provided
|
|
63
|
+
if (config.strategy === 'stacking' && !config.stacking) {
|
|
64
|
+
throw new CompilationError(
|
|
65
|
+
`Ensemble "${ruleSet.id}": strategy "stacking" requires config.stacking to be provided`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Tier IDs must be unique if tiers are configured
|
|
70
|
+
if (config.tiers && config.tiers.length > 0) {
|
|
71
|
+
const tierIds = new Set<string>();
|
|
72
|
+
for (const tier of config.tiers) {
|
|
73
|
+
if (tierIds.has(tier.id)) {
|
|
74
|
+
throw new CompilationError(
|
|
75
|
+
`Ensemble "${ruleSet.id}": duplicate tier id "${tier.id}"`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
tierIds.add(tier.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tag each member with type
|
|
83
|
+
const compiledMembers: CompiledEnsembleMember<T>[] = members.map(member => {
|
|
84
|
+
if ('ruleset' in member) {
|
|
85
|
+
return { ...member, type: 'scoring' as const };
|
|
86
|
+
}
|
|
87
|
+
return { ...member, type: 'custom' as const };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: ruleSet.id,
|
|
92
|
+
name: ruleSet.name,
|
|
93
|
+
mode: 'ensemble',
|
|
94
|
+
members: compiledMembers,
|
|
95
|
+
config
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble Engine
|
|
3
|
+
*
|
|
4
|
+
* Self-contained engine for multi-model score fusion.
|
|
5
|
+
* Orchestrates N member executions and fuses their results
|
|
6
|
+
* using configurable strategies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
WorkingMemory,
|
|
11
|
+
Fact,
|
|
12
|
+
FactInput,
|
|
13
|
+
FactChange
|
|
14
|
+
} from '../../core';
|
|
15
|
+
|
|
16
|
+
import type { TierDefinition } from '../scoring/types';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
CompiledEnsembleRuleSet,
|
|
20
|
+
EnsembleOptions,
|
|
21
|
+
EnsembleResult
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
import { EnsembleStrategy } from './strategy';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ensemble rule engine.
|
|
28
|
+
*
|
|
29
|
+
* Executes multiple member scoring rulesets (or custom executors) and fuses
|
|
30
|
+
* their results into a single composite score using configurable strategies.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const engine = new EnsembleEngine();
|
|
35
|
+
* engine.add({ type: 'Vendor', data: { revenue: 500000, compliance: true } });
|
|
36
|
+
*
|
|
37
|
+
* const result = engine.execute(compiledEnsemble);
|
|
38
|
+
* console.log(`Composite score: ${result.totalScore}`);
|
|
39
|
+
* console.log(`Agreement: ${result.agreement}`);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class EnsembleEngine {
|
|
43
|
+
private wm: WorkingMemory;
|
|
44
|
+
private strategy: EnsembleStrategy;
|
|
45
|
+
|
|
46
|
+
constructor(workingMemory?: WorkingMemory) {
|
|
47
|
+
this.wm = workingMemory ?? new WorkingMemory();
|
|
48
|
+
this.strategy = new EnsembleStrategy();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ========================================
|
|
52
|
+
// IWorkingMemory Implementation
|
|
53
|
+
// ========================================
|
|
54
|
+
|
|
55
|
+
add<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
56
|
+
return this.wm.add(input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
remove(factId: string): Fact | undefined {
|
|
60
|
+
return this.wm.remove(factId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
update<T = Record<string, any>>(input: FactInput<T>): Fact<T> {
|
|
64
|
+
return this.wm.update(input);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get(factId: string): Fact | undefined {
|
|
68
|
+
return this.wm.get(factId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getByType(type: string): Fact[] {
|
|
72
|
+
return this.wm.getByType(type);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getAll(): Fact[] {
|
|
76
|
+
return this.wm.getAll();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
has(factId: string): boolean {
|
|
80
|
+
return this.wm.has(factId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
size(): number {
|
|
84
|
+
return this.wm.size();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.wm.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getChanges(): FactChange[] {
|
|
92
|
+
return this.wm.getChanges();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clearChanges(): void {
|
|
96
|
+
this.wm.clearChanges();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ========================================
|
|
100
|
+
// Engine Execution
|
|
101
|
+
// ========================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Execute an ensemble ruleset.
|
|
105
|
+
*
|
|
106
|
+
* Collects all facts from working memory, executes each member engine
|
|
107
|
+
* with its own independent working memory, and fuses results using
|
|
108
|
+
* the configured strategy.
|
|
109
|
+
*
|
|
110
|
+
* @param ruleSet - Compiled ensemble ruleset
|
|
111
|
+
* @param options - Runtime options (reserved for future use)
|
|
112
|
+
* @returns Ensemble result with fused score and per-member breakdown
|
|
113
|
+
*/
|
|
114
|
+
execute<T extends TierDefinition = TierDefinition>(
|
|
115
|
+
ruleSet: CompiledEnsembleRuleSet<T>,
|
|
116
|
+
options: EnsembleOptions = {}
|
|
117
|
+
): EnsembleResult<T> {
|
|
118
|
+
// Collect all facts from WM as FactInput[] so each sub-engine gets its own fresh WM
|
|
119
|
+
const facts = this.wm.getAll().map(f => ({ type: f.type, data: f.data, id: f.id }));
|
|
120
|
+
return this.strategy.run(ruleSet, facts, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ========================================
|
|
124
|
+
// Utility Methods
|
|
125
|
+
// ========================================
|
|
126
|
+
|
|
127
|
+
getWorkingMemory(): WorkingMemory {
|
|
128
|
+
return this.wm;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble Engine — Multi-Model Score Fusion
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
export type {
|
|
7
|
+
FusionStrategy,
|
|
8
|
+
ScoreExtractor,
|
|
9
|
+
ConfidenceExtractor,
|
|
10
|
+
ScoringMemberDef,
|
|
11
|
+
CustomMemberDef,
|
|
12
|
+
EnsembleMemberDef,
|
|
13
|
+
StackingConfig,
|
|
14
|
+
EnsembleConfig,
|
|
15
|
+
EnsembleRuleSet,
|
|
16
|
+
CompiledEnsembleMember,
|
|
17
|
+
CompiledEnsembleRuleSet,
|
|
18
|
+
EnsembleOptions,
|
|
19
|
+
MemberResult,
|
|
20
|
+
EnsembleResult,
|
|
21
|
+
EnsembleSummaryOptions
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
// Compiler
|
|
25
|
+
export { compileEnsembleRuleSet } from './compiler';
|
|
26
|
+
|
|
27
|
+
// Strategy
|
|
28
|
+
export { EnsembleStrategy, ensembleStrategy } from './strategy';
|
|
29
|
+
|
|
30
|
+
// Engine
|
|
31
|
+
export { EnsembleEngine } from './engine';
|
|
32
|
+
|
|
33
|
+
// Summary
|
|
34
|
+
export { summarizeEnsemble } from './summary';
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble Engine Strategy
|
|
3
|
+
*
|
|
4
|
+
* Executes N member engines and fuses their results using configurable
|
|
5
|
+
* fusion strategies (weighted-average, median, min, max, voting, stacking).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ScoringEngine } from '../scoring/engine';
|
|
9
|
+
import type { ScoringTierMatch, TierDefinition, OutputBounds } from '../scoring/types';
|
|
10
|
+
import type { FactInput } from '../../core';
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
CompiledEnsembleRuleSet,
|
|
14
|
+
EnsembleOptions,
|
|
15
|
+
EnsembleResult,
|
|
16
|
+
MemberResult
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
// ========================================
|
|
20
|
+
// Output Bounds (reused from scoring)
|
|
21
|
+
// ========================================
|
|
22
|
+
|
|
23
|
+
function resolveOutputBounds(bounds: OutputBounds): { min: number; max: number } {
|
|
24
|
+
if (typeof bounds === 'object') return bounds;
|
|
25
|
+
switch (bounds) {
|
|
26
|
+
case 'percentage': return { min: 0, max: 100 };
|
|
27
|
+
case 'points': return { min: 0, max: 1000 };
|
|
28
|
+
case 'rating': return { min: 0, max: 10 };
|
|
29
|
+
case 'stars': return { min: 0, max: 5 };
|
|
30
|
+
case 'normalized': return { min: 0, max: 1 };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ========================================
|
|
35
|
+
// Strategy
|
|
36
|
+
// ========================================
|
|
37
|
+
|
|
38
|
+
export class EnsembleStrategy {
|
|
39
|
+
run<T extends TierDefinition = TierDefinition>(
|
|
40
|
+
ruleSet: CompiledEnsembleRuleSet<T>,
|
|
41
|
+
facts: FactInput[],
|
|
42
|
+
_options: EnsembleOptions
|
|
43
|
+
): EnsembleResult<T> {
|
|
44
|
+
const startTime = performance.now();
|
|
45
|
+
const { members, config } = ruleSet;
|
|
46
|
+
|
|
47
|
+
// 1. Execute all members
|
|
48
|
+
const memberResults: MemberResult[] = [];
|
|
49
|
+
const allFired: string[] = [];
|
|
50
|
+
|
|
51
|
+
for (const member of members) {
|
|
52
|
+
const memberStart = performance.now();
|
|
53
|
+
let score: number;
|
|
54
|
+
let confidence: number;
|
|
55
|
+
|
|
56
|
+
if (member.type === 'scoring') {
|
|
57
|
+
const engine = new ScoringEngine();
|
|
58
|
+
for (const fact of facts) {
|
|
59
|
+
engine.add(fact);
|
|
60
|
+
}
|
|
61
|
+
const result = engine.execute(member.ruleset, member.options);
|
|
62
|
+
score = result.totalScore;
|
|
63
|
+
confidence = result.confidence;
|
|
64
|
+
|
|
65
|
+
// Collect fired rules
|
|
66
|
+
for (const ruleId of result.fired) {
|
|
67
|
+
if (!allFired.includes(ruleId)) {
|
|
68
|
+
allFired.push(ruleId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const result = member.execute(facts);
|
|
73
|
+
score = member.extractScore(result);
|
|
74
|
+
confidence = member.extractConfidence ? member.extractConfidence(result) : 1.0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const memberTime = Math.round((performance.now() - memberStart) * 100) / 100;
|
|
78
|
+
|
|
79
|
+
memberResults.push({
|
|
80
|
+
memberId: member.id,
|
|
81
|
+
memberName: member.name,
|
|
82
|
+
score,
|
|
83
|
+
confidence,
|
|
84
|
+
weight: member.weight,
|
|
85
|
+
weightedScore: score * member.weight,
|
|
86
|
+
executionTimeMs: memberTime
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Fuse by strategy
|
|
91
|
+
let fusedScore: number;
|
|
92
|
+
let fusedConfidence: number;
|
|
93
|
+
|
|
94
|
+
switch (config.strategy) {
|
|
95
|
+
case 'weighted-average': {
|
|
96
|
+
fusedScore = memberResults.reduce((sum, m) => sum + m.score * m.weight, 0);
|
|
97
|
+
fusedConfidence = memberResults.reduce((sum, m) => sum + m.confidence * m.weight, 0);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'median': {
|
|
102
|
+
const sortedScores = [...memberResults].sort((a, b) => a.score - b.score);
|
|
103
|
+
const sortedConfidences = [...memberResults].sort((a, b) => a.confidence - b.confidence);
|
|
104
|
+
const mid = Math.floor(sortedScores.length / 2);
|
|
105
|
+
|
|
106
|
+
if (sortedScores.length % 2 === 0) {
|
|
107
|
+
fusedScore = (sortedScores[mid - 1].score + sortedScores[mid].score) / 2;
|
|
108
|
+
fusedConfidence = (sortedConfidences[mid - 1].confidence + sortedConfidences[mid].confidence) / 2;
|
|
109
|
+
} else {
|
|
110
|
+
fusedScore = sortedScores[mid].score;
|
|
111
|
+
fusedConfidence = sortedConfidences[mid].confidence;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'min': {
|
|
117
|
+
let minIdx = 0;
|
|
118
|
+
for (let i = 1; i < memberResults.length; i++) {
|
|
119
|
+
if (memberResults[i].score < memberResults[minIdx].score) {
|
|
120
|
+
minIdx = i;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
fusedScore = memberResults[minIdx].score;
|
|
124
|
+
fusedConfidence = memberResults[minIdx].confidence;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'max': {
|
|
129
|
+
let maxIdx = 0;
|
|
130
|
+
for (let i = 1; i < memberResults.length; i++) {
|
|
131
|
+
if (memberResults[i].score > memberResults[maxIdx].score) {
|
|
132
|
+
maxIdx = i;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
fusedScore = memberResults[maxIdx].score;
|
|
136
|
+
fusedConfidence = memberResults[maxIdx].confidence;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'voting': {
|
|
141
|
+
let winnerIdx = 0;
|
|
142
|
+
let maxProduct = memberResults[0].confidence * memberResults[0].weight;
|
|
143
|
+
for (let i = 1; i < memberResults.length; i++) {
|
|
144
|
+
const product = memberResults[i].confidence * memberResults[i].weight;
|
|
145
|
+
if (product > maxProduct) {
|
|
146
|
+
maxProduct = product;
|
|
147
|
+
winnerIdx = i;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
fusedScore = memberResults[winnerIdx].score;
|
|
151
|
+
fusedConfidence = memberResults[winnerIdx].confidence;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'stacking': {
|
|
156
|
+
const stackingConfig = config.stacking!;
|
|
157
|
+
const stackEngine = new ScoringEngine();
|
|
158
|
+
|
|
159
|
+
// Add member scores as facts
|
|
160
|
+
for (const mr of memberResults) {
|
|
161
|
+
stackEngine.add({
|
|
162
|
+
type: 'EnsembleMemberScore',
|
|
163
|
+
data: {
|
|
164
|
+
memberId: mr.memberId,
|
|
165
|
+
score: mr.score,
|
|
166
|
+
confidence: mr.confidence
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const stackResult = stackEngine.execute(stackingConfig.ruleset, stackingConfig.options);
|
|
172
|
+
fusedScore = stackResult.totalScore;
|
|
173
|
+
fusedConfidence = stackResult.confidence;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. Compute agreement: 1 - (stddev / (range / 2)), clamped to [0, 1]
|
|
179
|
+
const scores = memberResults.map(m => m.score);
|
|
180
|
+
const agreement = computeAgreement(scores, config.outputBounds);
|
|
181
|
+
|
|
182
|
+
// 4. Apply output bounds
|
|
183
|
+
if (config.outputBounds) {
|
|
184
|
+
const bounds = resolveOutputBounds(config.outputBounds);
|
|
185
|
+
fusedScore = Math.max(bounds.min, Math.min(bounds.max, fusedScore));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 5. Match tier
|
|
189
|
+
let tier: ScoringTierMatch<T> | undefined;
|
|
190
|
+
if (config.tiers && config.tiers.length > 0) {
|
|
191
|
+
const resolveThreshold = (t: number | '-Infinity'): number =>
|
|
192
|
+
t === '-Infinity' ? Number.NEGATIVE_INFINITY : t;
|
|
193
|
+
|
|
194
|
+
const sortedTiers = [...config.tiers].sort(
|
|
195
|
+
(a, b) => resolveThreshold(b.threshold) - resolveThreshold(a.threshold)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
for (const tierDef of sortedTiers) {
|
|
199
|
+
const threshold = resolveThreshold(tierDef.threshold);
|
|
200
|
+
if (fusedScore >= threshold) {
|
|
201
|
+
tier = {
|
|
202
|
+
...tierDef,
|
|
203
|
+
threshold
|
|
204
|
+
} as ScoringTierMatch<T>;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 6. Build contributions map
|
|
211
|
+
const contributions: Record<string, number> = {};
|
|
212
|
+
for (const mr of memberResults) {
|
|
213
|
+
contributions[mr.memberId] = mr.score;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const executionTimeMs = Math.round((performance.now() - startTime) * 100) / 100;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
totalScore: fusedScore,
|
|
220
|
+
confidence: fusedConfidence,
|
|
221
|
+
tier,
|
|
222
|
+
strategy: config.strategy,
|
|
223
|
+
memberResults,
|
|
224
|
+
agreement,
|
|
225
|
+
fired: allFired,
|
|
226
|
+
contributions,
|
|
227
|
+
executionTimeMs
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ========================================
|
|
233
|
+
// Agreement Calculation
|
|
234
|
+
// ========================================
|
|
235
|
+
|
|
236
|
+
function computeAgreement(scores: number[], outputBounds?: OutputBounds): number {
|
|
237
|
+
if (scores.length <= 1) return 1;
|
|
238
|
+
|
|
239
|
+
// Compute stddev
|
|
240
|
+
const mean = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
241
|
+
const variance = scores.reduce((sum, s) => sum + (s - mean) ** 2, 0) / scores.length;
|
|
242
|
+
const stddev = Math.sqrt(variance);
|
|
243
|
+
|
|
244
|
+
// Determine range
|
|
245
|
+
let range: number;
|
|
246
|
+
if (outputBounds) {
|
|
247
|
+
const bounds = resolveOutputBounds(outputBounds);
|
|
248
|
+
range = bounds.max - bounds.min;
|
|
249
|
+
} else {
|
|
250
|
+
const minScore = Math.min(...scores);
|
|
251
|
+
const maxScore = Math.max(...scores);
|
|
252
|
+
range = maxScore - minScore;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Avoid division by zero — all scores identical
|
|
256
|
+
if (range === 0) return 1;
|
|
257
|
+
|
|
258
|
+
const halfRange = range / 2;
|
|
259
|
+
return Math.max(0, Math.min(1, 1 - (stddev / halfRange)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Export a singleton instance for convenience
|
|
263
|
+
export const ensembleStrategy = new EnsembleStrategy();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble Summary
|
|
3
|
+
*
|
|
4
|
+
* Generates human-readable summary strings from ensemble results.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EnsembleResult, EnsembleSummaryOptions } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a human-readable summary string from an EnsembleResult.
|
|
11
|
+
*
|
|
12
|
+
* @param result - The ensemble result to summarize
|
|
13
|
+
* @param options - Optional display settings
|
|
14
|
+
* @returns Formatted multi-line summary string
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const result = engine.execute(compiledEnsemble);
|
|
19
|
+
* console.log(summarizeEnsemble(result));
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function summarizeEnsemble(
|
|
23
|
+
result: EnsembleResult,
|
|
24
|
+
options?: EnsembleSummaryOptions
|
|
25
|
+
): string {
|
|
26
|
+
const { maxMembers } = options ?? {};
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
|
|
29
|
+
// Header
|
|
30
|
+
lines.push(`Ensemble: ${result.strategy}`);
|
|
31
|
+
|
|
32
|
+
const tierLabel = result.tier ? ` (${result.tier.name ?? result.tier.id})` : '';
|
|
33
|
+
lines.push(`Score: ${result.totalScore}${tierLabel}`);
|
|
34
|
+
lines.push(`Confidence: ${result.confidence.toFixed(2)}`);
|
|
35
|
+
lines.push(`Agreement: ${result.agreement.toFixed(2)}`);
|
|
36
|
+
|
|
37
|
+
// Members
|
|
38
|
+
const members = maxMembers != null
|
|
39
|
+
? result.memberResults.slice(0, maxMembers)
|
|
40
|
+
: result.memberResults;
|
|
41
|
+
|
|
42
|
+
if (members.length > 0) {
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push('Members:');
|
|
45
|
+
for (const m of members) {
|
|
46
|
+
const label = m.memberName ?? m.memberId;
|
|
47
|
+
const weightStr = m.weight.toFixed(2);
|
|
48
|
+
lines.push(` [${weightStr}] ${label.padEnd(25)} → ${m.score} (confidence: ${m.confidence.toFixed(2)})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (maxMembers != null && result.memberResults.length > maxMembers) {
|
|
52
|
+
const remaining = result.memberResults.length - maxMembers;
|
|
53
|
+
lines.push(` ... and ${remaining} more`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|