@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.
Files changed (52) 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/ensemble/compiler.d.ts +13 -0
  4. package/dist/engines/ensemble/compiler.d.ts.map +1 -0
  5. package/dist/engines/ensemble/compiler.js +70 -0
  6. package/dist/engines/ensemble/compiler.js.map +1 -0
  7. package/dist/engines/ensemble/engine.d.ts +56 -0
  8. package/dist/engines/ensemble/engine.d.ts.map +1 -0
  9. package/dist/engines/ensemble/engine.js +99 -0
  10. package/dist/engines/ensemble/engine.js.map +1 -0
  11. package/dist/engines/ensemble/index.d.ts +9 -0
  12. package/dist/engines/ensemble/index.d.ts.map +1 -0
  13. package/dist/engines/ensemble/index.js +20 -0
  14. package/dist/engines/ensemble/index.js.map +1 -0
  15. package/dist/engines/ensemble/strategy.d.ts +14 -0
  16. package/dist/engines/ensemble/strategy.d.ts.map +1 -0
  17. package/dist/engines/ensemble/strategy.js +222 -0
  18. package/dist/engines/ensemble/strategy.js.map +1 -0
  19. package/dist/engines/ensemble/summary.d.ts +21 -0
  20. package/dist/engines/ensemble/summary.d.ts.map +1 -0
  21. package/dist/engines/ensemble/summary.js +50 -0
  22. package/dist/engines/ensemble/summary.js.map +1 -0
  23. package/dist/engines/ensemble/types.d.ts +89 -0
  24. package/dist/engines/ensemble/types.d.ts.map +1 -0
  25. package/dist/engines/ensemble/types.js +9 -0
  26. package/dist/engines/ensemble/types.js.map +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +10 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/promotion/index.d.ts +2 -2
  32. package/dist/promotion/index.d.ts.map +1 -1
  33. package/dist/promotion/index.js +3 -1
  34. package/dist/promotion/index.js.map +1 -1
  35. package/dist/promotion/promotion.d.ts +33 -2
  36. package/dist/promotion/promotion.d.ts.map +1 -1
  37. package/dist/promotion/promotion.js +97 -1
  38. package/dist/promotion/promotion.js.map +1 -1
  39. package/dist/promotion/types.d.ts +8 -0
  40. package/dist/promotion/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/core/types/rule.ts +1 -1
  43. package/src/engines/ensemble/compiler.ts +97 -0
  44. package/src/engines/ensemble/engine.ts +130 -0
  45. package/src/engines/ensemble/index.ts +34 -0
  46. package/src/engines/ensemble/strategy.ts +263 -0
  47. package/src/engines/ensemble/summary.ts +58 -0
  48. package/src/engines/ensemble/types.ts +137 -0
  49. package/src/index.ts +26 -0
  50. package/src/promotion/index.ts +2 -2
  51. package/src/promotion/promotion.ts +115 -2
  52. 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
+ }