@higher.archi/boe 1.0.30 → 1.0.31

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 (226) hide show
  1. package/dist/core/evaluation/decay.d.ts +13 -2
  2. package/dist/core/evaluation/decay.d.ts.map +1 -1
  3. package/dist/core/evaluation/decay.js +24 -0
  4. package/dist/core/evaluation/decay.js.map +1 -1
  5. package/dist/core/types/rule.d.ts +25 -1
  6. package/dist/core/types/rule.d.ts.map +1 -1
  7. package/dist/core/types/rule.js +28 -0
  8. package/dist/core/types/rule.js.map +1 -1
  9. package/dist/engines/bayesian/index.d.ts +1 -1
  10. package/dist/engines/bayesian/index.d.ts.map +1 -1
  11. package/dist/engines/bayesian/index.js +2 -1
  12. package/dist/engines/bayesian/index.js.map +1 -1
  13. package/dist/engines/bayesian/types.d.ts +10 -1
  14. package/dist/engines/bayesian/types.d.ts.map +1 -1
  15. package/dist/engines/bayesian/types.js +16 -1
  16. package/dist/engines/bayesian/types.js.map +1 -1
  17. package/dist/engines/constraint/types.d.ts +36 -5
  18. package/dist/engines/constraint/types.d.ts.map +1 -1
  19. package/dist/engines/constraint/types.js +44 -1
  20. package/dist/engines/constraint/types.js.map +1 -1
  21. package/dist/engines/decay/index.d.ts +1 -1
  22. package/dist/engines/decay/index.d.ts.map +1 -1
  23. package/dist/engines/decay/index.js +5 -1
  24. package/dist/engines/decay/index.js.map +1 -1
  25. package/dist/engines/decay/types.d.ts +26 -4
  26. package/dist/engines/decay/types.d.ts.map +1 -1
  27. package/dist/engines/decay/types.js +30 -1
  28. package/dist/engines/decay/types.js.map +1 -1
  29. package/dist/engines/defeasible/index.d.ts +1 -1
  30. package/dist/engines/defeasible/index.d.ts.map +1 -1
  31. package/dist/engines/defeasible/index.js +8 -1
  32. package/dist/engines/defeasible/index.js.map +1 -1
  33. package/dist/engines/defeasible/types.d.ts +40 -5
  34. package/dist/engines/defeasible/types.d.ts.map +1 -1
  35. package/dist/engines/defeasible/types.js +56 -1
  36. package/dist/engines/defeasible/types.js.map +1 -1
  37. package/dist/engines/ensemble/index.d.ts +1 -0
  38. package/dist/engines/ensemble/index.d.ts.map +1 -1
  39. package/dist/engines/ensemble/index.js +5 -1
  40. package/dist/engines/ensemble/index.js.map +1 -1
  41. package/dist/engines/ensemble/types.d.ts +17 -2
  42. package/dist/engines/ensemble/types.d.ts.map +1 -1
  43. package/dist/engines/ensemble/types.js +23 -0
  44. package/dist/engines/ensemble/types.js.map +1 -1
  45. package/dist/engines/expert/index.d.ts +1 -1
  46. package/dist/engines/expert/index.d.ts.map +1 -1
  47. package/dist/engines/expert/index.js +3 -1
  48. package/dist/engines/expert/index.js.map +1 -1
  49. package/dist/engines/expert/types.d.ts +11 -1
  50. package/dist/engines/expert/types.d.ts.map +1 -1
  51. package/dist/engines/expert/types.js +18 -1
  52. package/dist/engines/expert/types.js.map +1 -1
  53. package/dist/engines/fuzzy/fuzzy.types.d.ts +65 -8
  54. package/dist/engines/fuzzy/fuzzy.types.d.ts.map +1 -1
  55. package/dist/engines/fuzzy/fuzzy.types.js +89 -1
  56. package/dist/engines/fuzzy/fuzzy.types.js.map +1 -1
  57. package/dist/engines/loyalty/index.d.ts +1 -0
  58. package/dist/engines/loyalty/index.d.ts.map +1 -1
  59. package/dist/engines/loyalty/index.js +8 -1
  60. package/dist/engines/loyalty/index.js.map +1 -1
  61. package/dist/engines/loyalty/types.d.ts +36 -5
  62. package/dist/engines/loyalty/types.d.ts.map +1 -1
  63. package/dist/engines/loyalty/types.js +40 -0
  64. package/dist/engines/loyalty/types.js.map +1 -1
  65. package/dist/engines/menu-engineering/compiler.d.ts +11 -0
  66. package/dist/engines/menu-engineering/compiler.d.ts.map +1 -0
  67. package/dist/engines/menu-engineering/compiler.js +119 -0
  68. package/dist/engines/menu-engineering/compiler.js.map +1 -0
  69. package/dist/engines/menu-engineering/engine.d.ts +32 -0
  70. package/dist/engines/menu-engineering/engine.d.ts.map +1 -0
  71. package/dist/engines/menu-engineering/engine.js +40 -0
  72. package/dist/engines/menu-engineering/engine.js.map +1 -0
  73. package/dist/engines/menu-engineering/index.d.ts +9 -0
  74. package/dist/engines/menu-engineering/index.d.ts.map +1 -0
  75. package/dist/engines/menu-engineering/index.js +21 -0
  76. package/dist/engines/menu-engineering/index.js.map +1 -0
  77. package/dist/engines/menu-engineering/strategy.d.ts +18 -0
  78. package/dist/engines/menu-engineering/strategy.d.ts.map +1 -0
  79. package/dist/engines/menu-engineering/strategy.js +318 -0
  80. package/dist/engines/menu-engineering/strategy.js.map +1 -0
  81. package/dist/engines/menu-engineering/types.d.ts +187 -0
  82. package/dist/engines/menu-engineering/types.d.ts.map +1 -0
  83. package/dist/engines/menu-engineering/types.js +27 -0
  84. package/dist/engines/menu-engineering/types.js.map +1 -0
  85. package/dist/engines/monte-carlo/index.d.ts +1 -1
  86. package/dist/engines/monte-carlo/index.d.ts.map +1 -1
  87. package/dist/engines/monte-carlo/index.js +5 -1
  88. package/dist/engines/monte-carlo/index.js.map +1 -1
  89. package/dist/engines/monte-carlo/types.d.ts +16 -1
  90. package/dist/engines/monte-carlo/types.d.ts.map +1 -1
  91. package/dist/engines/monte-carlo/types.js +23 -1
  92. package/dist/engines/monte-carlo/types.js.map +1 -1
  93. package/dist/engines/negotiation/index.d.ts +1 -0
  94. package/dist/engines/negotiation/index.d.ts.map +1 -1
  95. package/dist/engines/negotiation/index.js +7 -1
  96. package/dist/engines/negotiation/index.js.map +1 -1
  97. package/dist/engines/negotiation/types.d.ts +23 -4
  98. package/dist/engines/negotiation/types.d.ts.map +1 -1
  99. package/dist/engines/negotiation/types.js +27 -0
  100. package/dist/engines/negotiation/types.js.map +1 -1
  101. package/dist/engines/prediction/index.d.ts +1 -1
  102. package/dist/engines/prediction/index.d.ts.map +1 -1
  103. package/dist/engines/prediction/index.js +6 -1
  104. package/dist/engines/prediction/index.js.map +1 -1
  105. package/dist/engines/prediction/types.d.ts +35 -5
  106. package/dist/engines/prediction/types.d.ts.map +1 -1
  107. package/dist/engines/prediction/types.js +39 -1
  108. package/dist/engines/prediction/types.js.map +1 -1
  109. package/dist/engines/pricing/index.d.ts +2 -2
  110. package/dist/engines/pricing/index.d.ts.map +1 -1
  111. package/dist/engines/pricing/index.js +3 -1
  112. package/dist/engines/pricing/index.js.map +1 -1
  113. package/dist/engines/pricing/types.d.ts +15 -1
  114. package/dist/engines/pricing/types.d.ts.map +1 -1
  115. package/dist/engines/pricing/types.js +16 -1
  116. package/dist/engines/pricing/types.js.map +1 -1
  117. package/dist/engines/ranking/index.d.ts +1 -1
  118. package/dist/engines/ranking/index.d.ts.map +1 -1
  119. package/dist/engines/ranking/index.js +6 -1
  120. package/dist/engines/ranking/index.js.map +1 -1
  121. package/dist/engines/ranking/types.d.ts +32 -5
  122. package/dist/engines/ranking/types.d.ts.map +1 -1
  123. package/dist/engines/ranking/types.js +36 -1
  124. package/dist/engines/ranking/types.js.map +1 -1
  125. package/dist/engines/recipe-costing/compiler.d.ts +11 -0
  126. package/dist/engines/recipe-costing/compiler.d.ts.map +1 -0
  127. package/dist/engines/recipe-costing/compiler.js +177 -0
  128. package/dist/engines/recipe-costing/compiler.js.map +1 -0
  129. package/dist/engines/recipe-costing/engine.d.ts +32 -0
  130. package/dist/engines/recipe-costing/engine.d.ts.map +1 -0
  131. package/dist/engines/recipe-costing/engine.js +40 -0
  132. package/dist/engines/recipe-costing/engine.js.map +1 -0
  133. package/dist/engines/recipe-costing/index.d.ts +9 -0
  134. package/dist/engines/recipe-costing/index.d.ts.map +1 -0
  135. package/dist/engines/recipe-costing/index.js +21 -0
  136. package/dist/engines/recipe-costing/index.js.map +1 -0
  137. package/dist/engines/recipe-costing/strategy.d.ts +20 -0
  138. package/dist/engines/recipe-costing/strategy.d.ts.map +1 -0
  139. package/dist/engines/recipe-costing/strategy.js +265 -0
  140. package/dist/engines/recipe-costing/strategy.js.map +1 -0
  141. package/dist/engines/recipe-costing/types.d.ts +213 -0
  142. package/dist/engines/recipe-costing/types.d.ts.map +1 -0
  143. package/dist/engines/recipe-costing/types.js +36 -0
  144. package/dist/engines/recipe-costing/types.js.map +1 -0
  145. package/dist/engines/scoring/index.d.ts +1 -1
  146. package/dist/engines/scoring/index.d.ts.map +1 -1
  147. package/dist/engines/scoring/index.js +3 -1
  148. package/dist/engines/scoring/index.js.map +1 -1
  149. package/dist/engines/scoring/types.d.ts +8 -1
  150. package/dist/engines/scoring/types.d.ts.map +1 -1
  151. package/dist/engines/scoring/types.js +18 -1
  152. package/dist/engines/scoring/types.js.map +1 -1
  153. package/dist/engines/sentiment/index.d.ts +1 -1
  154. package/dist/engines/sentiment/index.d.ts.map +1 -1
  155. package/dist/engines/sentiment/index.js +3 -1
  156. package/dist/engines/sentiment/index.js.map +1 -1
  157. package/dist/engines/sentiment/types.d.ts +13 -2
  158. package/dist/engines/sentiment/types.d.ts.map +1 -1
  159. package/dist/engines/sentiment/types.js +17 -1
  160. package/dist/engines/sentiment/types.js.map +1 -1
  161. package/dist/engines/state-machine/index.d.ts +1 -1
  162. package/dist/engines/state-machine/index.d.ts.map +1 -1
  163. package/dist/engines/state-machine/index.js +5 -1
  164. package/dist/engines/state-machine/index.js.map +1 -1
  165. package/dist/engines/state-machine/types.d.ts +7 -0
  166. package/dist/engines/state-machine/types.d.ts.map +1 -1
  167. package/dist/engines/state-machine/types.js +14 -0
  168. package/dist/engines/state-machine/types.js.map +1 -1
  169. package/dist/engines/utility/index.d.ts +2 -2
  170. package/dist/engines/utility/index.d.ts.map +1 -1
  171. package/dist/engines/utility/index.js +4 -1
  172. package/dist/engines/utility/index.js.map +1 -1
  173. package/dist/engines/utility/types.d.ts +21 -3
  174. package/dist/engines/utility/types.d.ts.map +1 -1
  175. package/dist/engines/utility/types.js +37 -1
  176. package/dist/engines/utility/types.js.map +1 -1
  177. package/dist/index.d.ts +28 -22
  178. package/dist/index.d.ts.map +1 -1
  179. package/dist/index.js +73 -3
  180. package/dist/index.js.map +1 -1
  181. package/package.json +1 -1
  182. package/src/core/evaluation/decay.ts +13 -2
  183. package/src/core/types/rule.ts +25 -1
  184. package/src/engines/bayesian/index.ts +1 -0
  185. package/src/engines/bayesian/types.ts +10 -8
  186. package/src/engines/constraint/types.ts +40 -11
  187. package/src/engines/decay/index.ts +4 -0
  188. package/src/engines/decay/types.ts +26 -4
  189. package/src/engines/defeasible/index.ts +7 -0
  190. package/src/engines/defeasible/types.ts +42 -18
  191. package/src/engines/ensemble/index.ts +6 -0
  192. package/src/engines/ensemble/types.ts +17 -13
  193. package/src/engines/expert/index.ts +1 -0
  194. package/src/engines/expert/types.ts +11 -9
  195. package/src/engines/fuzzy/fuzzy.types.ts +65 -31
  196. package/src/engines/loyalty/index.ts +9 -0
  197. package/src/engines/loyalty/types.ts +36 -5
  198. package/src/engines/menu-engineering/compiler.ts +145 -0
  199. package/src/engines/menu-engineering/engine.ts +48 -0
  200. package/src/engines/menu-engineering/index.ts +47 -0
  201. package/src/engines/menu-engineering/strategy.ts +414 -0
  202. package/src/engines/menu-engineering/types.ts +242 -0
  203. package/src/engines/monte-carlo/index.ts +1 -0
  204. package/src/engines/monte-carlo/types.ts +16 -21
  205. package/src/engines/negotiation/index.ts +8 -0
  206. package/src/engines/negotiation/types.ts +23 -4
  207. package/src/engines/prediction/index.ts +5 -0
  208. package/src/engines/prediction/types.ts +35 -5
  209. package/src/engines/pricing/index.ts +3 -1
  210. package/src/engines/pricing/types.ts +17 -1
  211. package/src/engines/ranking/index.ts +5 -0
  212. package/src/engines/ranking/types.ts +32 -11
  213. package/src/engines/recipe-costing/compiler.ts +219 -0
  214. package/src/engines/recipe-costing/engine.ts +48 -0
  215. package/src/engines/recipe-costing/index.ts +48 -0
  216. package/src/engines/recipe-costing/strategy.ts +357 -0
  217. package/src/engines/recipe-costing/types.ts +269 -0
  218. package/src/engines/scoring/index.ts +2 -0
  219. package/src/engines/scoring/types.ts +8 -6
  220. package/src/engines/sentiment/index.ts +2 -0
  221. package/src/engines/sentiment/types.ts +13 -2
  222. package/src/engines/state-machine/index.ts +3 -0
  223. package/src/engines/state-machine/types.ts +8 -0
  224. package/src/engines/utility/index.ts +5 -0
  225. package/src/engines/utility/types.ts +23 -3
  226. package/src/index.ts +146 -8
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Menu Engineering Engine Strategy
3
+ *
4
+ * Core execution logic for all menu engineering strategies:
5
+ * - classify: categorize items into Stars/Plowhorses/Puzzles/Dogs
6
+ * - price-optimization: model price change impacts with elasticity
7
+ * - mix-analysis: analyze category/daypart mix with divergence detection
8
+ */
9
+
10
+ import type {
11
+ CompiledMenuEngineeringRuleSet,
12
+ CompiledClassifyRuleSet,
13
+ CompiledPriceOptimizationRuleSet,
14
+ CompiledMixAnalysisRuleSet,
15
+ MenuItem,
16
+ MenuClassification,
17
+ ClassifiedItem,
18
+ MenuSummary,
19
+ PriceOptimizationItem,
20
+ CategoryMixResult,
21
+ ClassifyResult,
22
+ PriceOptimizationResult,
23
+ MixAnalysisResult,
24
+ MenuEngineeringResult,
25
+ MenuEngineeringOptions
26
+ } from './types';
27
+
28
+ // ========================================
29
+ // Helpers
30
+ // ========================================
31
+
32
+ function round(value: number, decimals: number): number {
33
+ const factor = Math.pow(10, decimals);
34
+ return Math.round(value * factor) / factor;
35
+ }
36
+
37
+ function classifyItem(
38
+ salesMixPct: number,
39
+ contributionMargin: number,
40
+ mixThreshold: number,
41
+ marginThreshold: number
42
+ ): MenuClassification {
43
+ const highMix = salesMixPct >= mixThreshold;
44
+ const highMargin = contributionMargin >= marginThreshold;
45
+
46
+ if (highMix && highMargin) return 'star';
47
+ if (highMix && !highMargin) return 'plowhorse';
48
+ if (!highMix && highMargin) return 'puzzle';
49
+ return 'dog';
50
+ }
51
+
52
+ function getRecommendation(classification: MenuClassification): string {
53
+ switch (classification) {
54
+ case 'star':
55
+ return 'Maintain quality and visibility. Signature item -- protect pricing and placement.';
56
+ case 'plowhorse':
57
+ return 'Popular but low margin. Consider portion adjustments, ingredient substitution, or slight price increase.';
58
+ case 'puzzle':
59
+ return 'High margin but low sales. Improve menu placement, rename, or bundle with popular items.';
60
+ case 'dog':
61
+ return 'Low popularity and low margin. Consider removing, repositioning, or reworking the recipe.';
62
+ }
63
+ }
64
+
65
+ function computeClassifiedItems(
66
+ items: MenuItem[],
67
+ mixThreshold: number,
68
+ marginThreshold: number
69
+ ): { classifiedItems: ClassifiedItem[]; totalSalesCount: number } {
70
+ const totalSalesCount = items.reduce((sum, item) => sum + item.salesCount, 0);
71
+
72
+ const classifiedItems: ClassifiedItem[] = items.map(item => {
73
+ const contributionMargin = round(item.sellingPrice - item.plateCost, 2);
74
+ const foodCostPct = item.sellingPrice > 0
75
+ ? round((item.plateCost / item.sellingPrice) * 100, 2)
76
+ : 0;
77
+ const salesMixPct = totalSalesCount > 0
78
+ ? round((item.salesCount / totalSalesCount) * 100, 2)
79
+ : 0;
80
+
81
+ const classification = classifyItem(salesMixPct, contributionMargin, mixThreshold, marginThreshold);
82
+
83
+ return {
84
+ id: item.id,
85
+ name: item.name,
86
+ category: item.category,
87
+ sellingPrice: item.sellingPrice,
88
+ plateCost: item.plateCost,
89
+ salesCount: item.salesCount,
90
+ contributionMargin,
91
+ foodCostPct,
92
+ salesMixPct,
93
+ classification,
94
+ recommendation: getRecommendation(classification)
95
+ };
96
+ });
97
+
98
+ return { classifiedItems, totalSalesCount };
99
+ }
100
+
101
+ function computeSummary(items: ClassifiedItem[]): MenuSummary {
102
+ const totalItems = items.length;
103
+ const totalSalesCount = items.reduce((sum, i) => sum + i.salesCount, 0);
104
+ const totalRevenue = items.reduce((sum, i) => sum + i.sellingPrice * i.salesCount, 0);
105
+ const totalCost = items.reduce((sum, i) => sum + i.plateCost * i.salesCount, 0);
106
+ const totalProfit = round(totalRevenue - totalCost, 2);
107
+
108
+ const avgContributionMargin = totalItems > 0
109
+ ? round(items.reduce((sum, i) => sum + i.contributionMargin, 0) / totalItems, 2)
110
+ : 0;
111
+ const avgFoodCostPct = totalItems > 0
112
+ ? round(items.reduce((sum, i) => sum + i.foodCostPct, 0) / totalItems, 2)
113
+ : 0;
114
+ const avgSalesMixPct = totalItems > 0
115
+ ? round(100 / totalItems, 2)
116
+ : 0;
117
+
118
+ const classificationCounts: Record<MenuClassification, number> = {
119
+ star: 0,
120
+ plowhorse: 0,
121
+ puzzle: 0,
122
+ dog: 0
123
+ };
124
+ for (const item of items) {
125
+ classificationCounts[item.classification]++;
126
+ }
127
+
128
+ return {
129
+ totalItems,
130
+ totalSalesCount,
131
+ totalRevenue: round(totalRevenue, 2),
132
+ totalCost: round(totalCost, 2),
133
+ totalProfit,
134
+ avgContributionMargin,
135
+ avgFoodCostPct,
136
+ avgSalesMixPct,
137
+ classificationCounts
138
+ };
139
+ }
140
+
141
+ // ========================================
142
+ // Strategy Executor
143
+ // ========================================
144
+
145
+ export class MenuEngineeringExecutor {
146
+ run(
147
+ ruleSet: CompiledMenuEngineeringRuleSet,
148
+ _options: MenuEngineeringOptions = {}
149
+ ): MenuEngineeringResult {
150
+ switch (ruleSet.strategy) {
151
+ case 'classify':
152
+ return this.runClassify(ruleSet);
153
+ case 'price-optimization':
154
+ return this.runPriceOptimization(ruleSet);
155
+ case 'mix-analysis':
156
+ return this.runMixAnalysis(ruleSet);
157
+ default:
158
+ throw new Error(`Unknown menu engineering strategy: '${(ruleSet as any).strategy}'`);
159
+ }
160
+ }
161
+
162
+ // ========================================
163
+ // Classify Strategy
164
+ // ========================================
165
+
166
+ private runClassify(ruleSet: CompiledClassifyRuleSet): ClassifyResult {
167
+ const startTime = performance.now();
168
+
169
+ // Compute thresholds
170
+ const items = ruleSet.items;
171
+ const totalSalesCount = items.reduce((sum, i) => sum + i.salesCount, 0);
172
+ const margins = items.map(i => i.sellingPrice - i.plateCost);
173
+ const mixPcts = items.map(i => totalSalesCount > 0 ? (i.salesCount / totalSalesCount) * 100 : 0);
174
+
175
+ const avgMargin = margins.reduce((sum, m) => sum + m, 0) / margins.length;
176
+ const avgMix = mixPcts.reduce((sum, m) => sum + m, 0) / mixPcts.length;
177
+
178
+ const mixThreshold = ruleSet.config.mixThresholdMode === 'custom'
179
+ ? ruleSet.config.customMixThreshold
180
+ : avgMix;
181
+ const marginThreshold = ruleSet.config.marginThresholdMode === 'custom'
182
+ ? ruleSet.config.customMarginThreshold
183
+ : avgMargin;
184
+
185
+ const { classifiedItems } = computeClassifiedItems(items, mixThreshold, marginThreshold);
186
+ const summary = computeSummary(classifiedItems);
187
+
188
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
189
+
190
+ return {
191
+ items: classifiedItems,
192
+ summary,
193
+ executionTimeMs
194
+ };
195
+ }
196
+
197
+ // ========================================
198
+ // Price Optimization Strategy
199
+ // ========================================
200
+
201
+ private runPriceOptimization(ruleSet: CompiledPriceOptimizationRuleSet): PriceOptimizationResult {
202
+ const startTime = performance.now();
203
+
204
+ // Run baseline classify
205
+ const items = ruleSet.items;
206
+ const totalSalesCount = items.reduce((sum, i) => sum + i.salesCount, 0);
207
+ const margins = items.map(i => i.sellingPrice - i.plateCost);
208
+ const mixPcts = items.map(i => totalSalesCount > 0 ? (i.salesCount / totalSalesCount) * 100 : 0);
209
+
210
+ const avgMargin = margins.reduce((sum, m) => sum + m, 0) / margins.length;
211
+ const avgMix = mixPcts.reduce((sum, m) => sum + m, 0) / mixPcts.length;
212
+
213
+ const { classifiedItems: baselineItems } = computeClassifiedItems(items, avgMix, avgMargin);
214
+
215
+ // Build lookup maps
216
+ const baselineMap = new Map(baselineItems.map(i => [i.id, i]));
217
+ const priceChangeMap = new Map(ruleSet.config.priceChanges.map(pc => [pc.itemId, pc]));
218
+ const elasticityMap = new Map<string, number>();
219
+
220
+ for (const e of ruleSet.config.elasticities) {
221
+ if (e.itemId) {
222
+ elasticityMap.set(`item:${e.itemId}`, e.elasticity);
223
+ }
224
+ if (e.category) {
225
+ elasticityMap.set(`cat:${e.category}`, e.elasticity);
226
+ }
227
+ }
228
+
229
+ // Apply price changes and compute new projected items
230
+ const modifiedItems: MenuItem[] = items.map(item => {
231
+ const change = priceChangeMap.get(item.id);
232
+ if (!change) return item;
233
+ return { ...item, sellingPrice: change.newPrice };
234
+ });
235
+
236
+ // Estimate new sales counts based on elasticity
237
+ const projectedItems: MenuItem[] = modifiedItems.map(item => {
238
+ const change = priceChangeMap.get(item.id);
239
+ if (!change) return item;
240
+
241
+ const originalItem = items.find(i => i.id === item.id)!;
242
+ const priceChangePct = (change.newPrice - originalItem.sellingPrice) / originalItem.sellingPrice;
243
+
244
+ // Resolve elasticity: item-specific > category > default
245
+ let elasticity = ruleSet.config.defaultElasticity;
246
+ if (elasticityMap.has(`item:${item.id}`)) {
247
+ elasticity = elasticityMap.get(`item:${item.id}`)!;
248
+ } else if (item.category && elasticityMap.has(`cat:${item.category}`)) {
249
+ elasticity = elasticityMap.get(`cat:${item.category}`)!;
250
+ }
251
+
252
+ const estimatedNewSalesCount = Math.max(0, Math.round(
253
+ originalItem.salesCount * (1 + elasticity * priceChangePct)
254
+ ));
255
+
256
+ return { ...item, salesCount: estimatedNewSalesCount };
257
+ });
258
+
259
+ // Re-classify with new data
260
+ const newTotalSalesCount = projectedItems.reduce((sum, i) => sum + i.salesCount, 0);
261
+ const newMargins = projectedItems.map(i => i.sellingPrice - i.plateCost);
262
+ const newMixPcts = projectedItems.map(i =>
263
+ newTotalSalesCount > 0 ? (i.salesCount / newTotalSalesCount) * 100 : 0
264
+ );
265
+
266
+ const newAvgMargin = newMargins.reduce((sum, m) => sum + m, 0) / newMargins.length;
267
+ const newAvgMix = newMixPcts.reduce((sum, m) => sum + m, 0) / newMixPcts.length;
268
+
269
+ const { classifiedItems: newClassifiedItems } = computeClassifiedItems(
270
+ projectedItems, newAvgMix, newAvgMargin
271
+ );
272
+ const newClassifiedMap = new Map(newClassifiedItems.map(i => [i.id, i]));
273
+
274
+ // Build optimization results for changed items
275
+ const optimizationItems: PriceOptimizationItem[] = [];
276
+ for (const change of ruleSet.config.priceChanges) {
277
+ const baseline = baselineMap.get(change.itemId)!;
278
+ const projected = newClassifiedMap.get(change.itemId)!;
279
+ const originalItem = items.find(i => i.id === change.itemId)!;
280
+
281
+ const priceChangePct = round(
282
+ ((change.newPrice - originalItem.sellingPrice) / originalItem.sellingPrice) * 100, 2
283
+ );
284
+
285
+ const originalRevenue = originalItem.sellingPrice * originalItem.salesCount;
286
+ const originalProfit = baseline.contributionMargin * originalItem.salesCount;
287
+ const projectedRevenue = projected.sellingPrice * projected.salesCount;
288
+ const projectedProfit = projected.contributionMargin * projected.salesCount;
289
+
290
+ optimizationItems.push({
291
+ id: change.itemId,
292
+ name: baseline.name,
293
+ originalPrice: originalItem.sellingPrice,
294
+ newPrice: change.newPrice,
295
+ priceChangePct,
296
+ originalMargin: baseline.contributionMargin,
297
+ newMargin: projected.contributionMargin,
298
+ estimatedNewSalesCount: projected.salesCount,
299
+ originalClassification: baseline.classification,
300
+ newClassification: projected.classification,
301
+ projectedRevenueChange: round(projectedRevenue - originalRevenue, 2),
302
+ projectedProfitChange: round(projectedProfit - originalProfit, 2)
303
+ });
304
+ }
305
+
306
+ const summary = computeSummary(newClassifiedItems);
307
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
308
+
309
+ return {
310
+ items: optimizationItems,
311
+ summary,
312
+ executionTimeMs
313
+ };
314
+ }
315
+
316
+ // ========================================
317
+ // Mix Analysis Strategy
318
+ // ========================================
319
+
320
+ private runMixAnalysis(ruleSet: CompiledMixAnalysisRuleSet): MixAnalysisResult {
321
+ const startTime = performance.now();
322
+
323
+ const items = ruleSet.items;
324
+ const groupField = ruleSet.config.groupBy === 'daypart'
325
+ ? ruleSet.config.daypartField
326
+ : 'category';
327
+
328
+ // Group items
329
+ const groups = new Map<string, MenuItem[]>();
330
+ for (const item of items) {
331
+ const key = (item as any)[groupField] as string ?? 'uncategorized';
332
+ if (!groups.has(key)) {
333
+ groups.set(key, []);
334
+ }
335
+ groups.get(key)!.push(item);
336
+ }
337
+
338
+ // Compute overall totals for mix percentages
339
+ const overallTotalSalesCount = items.reduce((sum, i) => sum + i.salesCount, 0);
340
+ const overallTotalRevenue = items.reduce((sum, i) => sum + i.sellingPrice * i.salesCount, 0);
341
+ const overallTotalProfit = items.reduce(
342
+ (sum, i) => sum + (i.sellingPrice - i.plateCost) * i.salesCount, 0
343
+ );
344
+
345
+ // Run classify within each group and compute category-level metrics
346
+ const categories: CategoryMixResult[] = [];
347
+ for (const [category, groupItems] of groups) {
348
+ // Classify within group
349
+ const totalGroupSales = groupItems.reduce((sum, i) => sum + i.salesCount, 0);
350
+ const groupMargins = groupItems.map(i => i.sellingPrice - i.plateCost);
351
+ const groupMixPcts = groupItems.map(i =>
352
+ totalGroupSales > 0 ? (i.salesCount / totalGroupSales) * 100 : 0
353
+ );
354
+ const groupAvgMargin = groupMargins.reduce((sum, m) => sum + m, 0) / groupMargins.length;
355
+ const groupAvgMix = groupMixPcts.reduce((sum, m) => sum + m, 0) / groupMixPcts.length;
356
+
357
+ const { classifiedItems } = computeClassifiedItems(groupItems, groupAvgMix, groupAvgMargin);
358
+
359
+ const totalRevenue = groupItems.reduce((sum, i) => sum + i.sellingPrice * i.salesCount, 0);
360
+ const totalCost = groupItems.reduce((sum, i) => sum + i.plateCost * i.salesCount, 0);
361
+ const totalProfit = totalRevenue - totalCost;
362
+
363
+ const avgContributionMargin = groupItems.length > 0
364
+ ? round(groupItems.reduce((sum, i) => sum + (i.sellingPrice - i.plateCost), 0) / groupItems.length, 2)
365
+ : 0;
366
+
367
+ categories.push({
368
+ category,
369
+ itemCount: groupItems.length,
370
+ totalSalesCount: totalGroupSales,
371
+ totalRevenue: round(totalRevenue, 2),
372
+ totalCost: round(totalCost, 2),
373
+ totalProfit: round(totalProfit, 2),
374
+ avgContributionMargin,
375
+ salesMixPct: overallTotalSalesCount > 0
376
+ ? round((totalGroupSales / overallTotalSalesCount) * 100, 2)
377
+ : 0,
378
+ revenueMixPct: overallTotalRevenue > 0
379
+ ? round((totalRevenue / overallTotalRevenue) * 100, 2)
380
+ : 0,
381
+ profitMixPct: overallTotalProfit > 0
382
+ ? round((totalProfit / overallTotalProfit) * 100, 2)
383
+ : 0,
384
+ items: classifiedItems
385
+ });
386
+ }
387
+
388
+ // Sort categories by revenue descending
389
+ categories.sort((a, b) => b.totalRevenue - a.totalRevenue);
390
+
391
+ // Compute overall summary using all items classified globally
392
+ const allMargins = items.map(i => i.sellingPrice - i.plateCost);
393
+ const allMixPcts = items.map(i =>
394
+ overallTotalSalesCount > 0 ? (i.salesCount / overallTotalSalesCount) * 100 : 0
395
+ );
396
+ const overallAvgMargin = allMargins.reduce((sum, m) => sum + m, 0) / allMargins.length;
397
+ const overallAvgMix = allMixPcts.reduce((sum, m) => sum + m, 0) / allMixPcts.length;
398
+ const { classifiedItems: allClassified } = computeClassifiedItems(
399
+ items, overallAvgMix, overallAvgMargin
400
+ );
401
+ const summary = computeSummary(allClassified);
402
+
403
+ const executionTimeMs = round((performance.now() - startTime) * 100, 0) / 100;
404
+
405
+ return {
406
+ categories,
407
+ summary,
408
+ executionTimeMs
409
+ };
410
+ }
411
+ }
412
+
413
+ /** Singleton instance */
414
+ export const menuEngineeringStrategy = new MenuEngineeringExecutor();
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Menu Engineering Engine Types
3
+ *
4
+ * Classifies menu items into Stars/Plowhorses/Puzzles/Dogs based on
5
+ * sales mix percentage and contribution margin. Supports price optimization
6
+ * with elasticity modeling and category-level mix analysis.
7
+ */
8
+
9
+ // ========================================
10
+ // Semantic Types
11
+ // ========================================
12
+
13
+ /** Menu engineering strategy to use */
14
+ export const MenuEngineeringStrategies = {
15
+ classify: 'classify',
16
+ priceOptimization: 'price-optimization',
17
+ mixAnalysis: 'mix-analysis',
18
+ } as const;
19
+ export type MenuEngineeringStrategy = typeof MenuEngineeringStrategies[keyof typeof MenuEngineeringStrategies];
20
+
21
+ /** Menu item classification quadrant */
22
+ export const MenuClassifications = {
23
+ star: 'star',
24
+ plowhorse: 'plowhorse',
25
+ puzzle: 'puzzle',
26
+ dog: 'dog',
27
+ } as const;
28
+ export type MenuClassification = typeof MenuClassifications[keyof typeof MenuClassifications];
29
+
30
+ // ========================================
31
+ // Core Domain Types
32
+ // ========================================
33
+
34
+ /** A menu item with sales and cost data */
35
+ export type MenuItem = {
36
+ id: string;
37
+ name: string;
38
+ category?: string;
39
+ sellingPrice: number;
40
+ plateCost: number;
41
+ salesCount: number;
42
+ };
43
+
44
+ /** Price elasticity for an item or category */
45
+ export type PriceElasticity = {
46
+ itemId?: string;
47
+ category?: string;
48
+ elasticity: number;
49
+ };
50
+
51
+ /** A proposed price change for a menu item */
52
+ export type PriceChange = {
53
+ itemId: string;
54
+ newPrice: number;
55
+ };
56
+
57
+ // ========================================
58
+ // Config Types
59
+ // ========================================
60
+
61
+ /** Configuration for classify strategy */
62
+ export type ClassifyConfig = {
63
+ mixThresholdMode?: 'average' | 'custom';
64
+ customMixThreshold?: number;
65
+ marginThresholdMode?: 'average' | 'custom';
66
+ customMarginThreshold?: number;
67
+ };
68
+
69
+ /** Configuration for price-optimization strategy */
70
+ export type PriceOptimizationConfig = {
71
+ priceChanges: PriceChange[];
72
+ elasticities?: PriceElasticity[];
73
+ defaultElasticity?: number;
74
+ };
75
+
76
+ /** Configuration for mix-analysis strategy */
77
+ export type MixAnalysisConfig = {
78
+ groupBy?: 'category' | 'daypart';
79
+ daypartField?: string;
80
+ };
81
+
82
+ // ========================================
83
+ // Source RuleSet Types (Discriminated Union)
84
+ // ========================================
85
+
86
+ type MenuEngineeringRuleSetBase = {
87
+ id: string;
88
+ name?: string;
89
+ mode: 'menu-engineering';
90
+ items: MenuItem[];
91
+ };
92
+
93
+ /** Classify strategy: categorize items into quadrants */
94
+ export type ClassifyRuleSet = MenuEngineeringRuleSetBase & {
95
+ strategy: 'classify';
96
+ config?: ClassifyConfig;
97
+ };
98
+
99
+ /** Price-optimization strategy: model price change impacts */
100
+ export type PriceOptimizationRuleSet = MenuEngineeringRuleSetBase & {
101
+ strategy: 'price-optimization';
102
+ config: PriceOptimizationConfig;
103
+ };
104
+
105
+ /** Mix-analysis strategy: analyze category/daypart mix */
106
+ export type MixAnalysisRuleSet = MenuEngineeringRuleSetBase & {
107
+ strategy: 'mix-analysis';
108
+ config?: MixAnalysisConfig;
109
+ };
110
+
111
+ export type MenuEngineeringRuleSet =
112
+ | ClassifyRuleSet
113
+ | PriceOptimizationRuleSet
114
+ | MixAnalysisRuleSet;
115
+
116
+ // ========================================
117
+ // Compiled RuleSet Types
118
+ // ========================================
119
+
120
+ type CompiledMenuEngineeringRuleSetBase = {
121
+ id: string;
122
+ name?: string;
123
+ mode: 'menu-engineering';
124
+ items: MenuItem[];
125
+ };
126
+
127
+ export type CompiledClassifyRuleSet = CompiledMenuEngineeringRuleSetBase & {
128
+ strategy: 'classify';
129
+ config: Required<ClassifyConfig>;
130
+ };
131
+
132
+ export type CompiledPriceOptimizationRuleSet = CompiledMenuEngineeringRuleSetBase & {
133
+ strategy: 'price-optimization';
134
+ config: {
135
+ priceChanges: PriceChange[];
136
+ elasticities: PriceElasticity[];
137
+ defaultElasticity: number;
138
+ };
139
+ };
140
+
141
+ export type CompiledMixAnalysisRuleSet = CompiledMenuEngineeringRuleSetBase & {
142
+ strategy: 'mix-analysis';
143
+ config: {
144
+ groupBy: 'category' | 'daypart';
145
+ daypartField: string;
146
+ };
147
+ };
148
+
149
+ export type CompiledMenuEngineeringRuleSet =
150
+ | CompiledClassifyRuleSet
151
+ | CompiledPriceOptimizationRuleSet
152
+ | CompiledMixAnalysisRuleSet;
153
+
154
+ // ========================================
155
+ // Result Types
156
+ // ========================================
157
+
158
+ /** A classified menu item with computed metrics */
159
+ export type ClassifiedItem = {
160
+ id: string;
161
+ name: string;
162
+ category?: string;
163
+ sellingPrice: number;
164
+ plateCost: number;
165
+ salesCount: number;
166
+ contributionMargin: number;
167
+ foodCostPct: number;
168
+ salesMixPct: number;
169
+ classification: MenuClassification;
170
+ recommendation: string;
171
+ };
172
+
173
+ /** Summary statistics for the menu */
174
+ export type MenuSummary = {
175
+ totalItems: number;
176
+ totalSalesCount: number;
177
+ totalRevenue: number;
178
+ totalCost: number;
179
+ totalProfit: number;
180
+ avgContributionMargin: number;
181
+ avgFoodCostPct: number;
182
+ avgSalesMixPct: number;
183
+ classificationCounts: Record<MenuClassification, number>;
184
+ };
185
+
186
+ /** A price-optimized menu item with projected impact */
187
+ export type PriceOptimizationItem = {
188
+ id: string;
189
+ name: string;
190
+ originalPrice: number;
191
+ newPrice: number;
192
+ priceChangePct: number;
193
+ originalMargin: number;
194
+ newMargin: number;
195
+ estimatedNewSalesCount: number;
196
+ originalClassification: MenuClassification;
197
+ newClassification: MenuClassification;
198
+ projectedRevenueChange: number;
199
+ projectedProfitChange: number;
200
+ };
201
+
202
+ /** Category-level mix analysis result */
203
+ export type CategoryMixResult = {
204
+ category: string;
205
+ itemCount: number;
206
+ totalSalesCount: number;
207
+ totalRevenue: number;
208
+ totalCost: number;
209
+ totalProfit: number;
210
+ avgContributionMargin: number;
211
+ salesMixPct: number;
212
+ revenueMixPct: number;
213
+ profitMixPct: number;
214
+ items: ClassifiedItem[];
215
+ };
216
+
217
+ /** Result from classify strategy */
218
+ export type ClassifyResult = {
219
+ items: ClassifiedItem[];
220
+ summary: MenuSummary;
221
+ executionTimeMs: number;
222
+ };
223
+
224
+ /** Result from price-optimization strategy */
225
+ export type PriceOptimizationResult = {
226
+ items: PriceOptimizationItem[];
227
+ summary: MenuSummary;
228
+ executionTimeMs: number;
229
+ };
230
+
231
+ /** Result from mix-analysis strategy */
232
+ export type MixAnalysisResult = {
233
+ categories: CategoryMixResult[];
234
+ summary: MenuSummary;
235
+ executionTimeMs: number;
236
+ };
237
+
238
+ /** Union of all menu engineering results */
239
+ export type MenuEngineeringResult = ClassifyResult | PriceOptimizationResult | MixAnalysisResult;
240
+
241
+ /** Runtime options */
242
+ export type MenuEngineeringOptions = Record<string, never>;
@@ -9,6 +9,7 @@
9
9
  // Types
10
10
  export {
11
11
  // Estimate types
12
+ EstimateTypes,
12
13
  EstimateType,
13
14
  PercentOrNumber,
14
15
  // Variable types