@holoscript/plugin-retail-ecommerce 2.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # @holoscript/plugin-retail-ecommerce
2
+
3
+ ## 2.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [c64fc1a]
8
+ - @holoscript/core@8.0.6
9
+
10
+ ## 2.0.0
11
+
12
+ ### Patch Changes
13
+
14
+ - @holoscript/core@6.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 HoloScript Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@holoscript/plugin-retail-ecommerce",
3
+ "version": "2.0.1",
4
+ "description": "HoloScript domain plugin for retail-ecommerce",
5
+ "main": "src/index.ts",
6
+ "peerDependencies": {
7
+ "@holoscript/core": "8.0.6"
8
+ },
9
+ "license": "MIT",
10
+ "scripts": {
11
+ "test": "vitest run --passWithNoTests",
12
+ "test:coverage": "vitest run --coverage --passWithNoTests"
13
+ }
14
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Retail & e-commerce solver tests — retail-ecommerce-plugin
3
+ *
4
+ * Reference values verified against:
5
+ * - Harris FW (1913) Operations and Cost — EOQ formula
6
+ * - Philips RL (2005) Pricing and Revenue Optimization — elasticity
7
+ * - CLV standard industry formulas (AGSM)
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import {
12
+ economicOrderQuantity,
13
+ priceOptimization,
14
+ markdownOptimization,
15
+ customerLifetimeValue,
16
+ conversionFunnelAnalysis,
17
+ abcClassification,
18
+ inventoryMetrics,
19
+ buildRetailReceipt,
20
+ } from '../retailsolver';
21
+
22
+ // ─── EOQ ─────────────────────────────────────────────────────────────────────
23
+
24
+ describe('economicOrderQuantity', () => {
25
+ /**
26
+ * Classic EOQ example: D=1000, S=$10, H=$2.50/unit/yr
27
+ * EOQ = √(2×1000×10/2.5) = √8000 ≈ 89.44
28
+ * Orders/year = 1000/89.44 ≈ 11.18
29
+ * TAC = (1000/89.44)×10 + (89.44/2)×2.5 ≈ $223.61
30
+ */
31
+ it('EOQ = √(2DS/H)', () => {
32
+ const r = economicOrderQuantity(1000, 10, 2.5);
33
+ expect(r.eoq).toBeCloseTo(Math.sqrt(2 * 1000 * 10 / 2.5), 1);
34
+ });
35
+
36
+ it('ordersPerYear = demand / EOQ', () => {
37
+ const r = economicOrderQuantity(1000, 10, 2.5);
38
+ expect(r.ordersPerYear).toBeCloseTo(1000 / r.eoq, 4);
39
+ });
40
+
41
+ it('TAC = (D/EOQ)×S + (EOQ/2)×H', () => {
42
+ const r = economicOrderQuantity(1000, 10, 2.5);
43
+ const expected = (1000 / r.eoq) * 10 + (r.eoq / 2) * 2.5;
44
+ expect(r.totalAnnualCost).toBeCloseTo(expected, 1);
45
+ });
46
+
47
+ it('reorder point = daily demand × lead time', () => {
48
+ const r = economicOrderQuantity(2500, 50, 5, 5, 250);
49
+ // Daily demand = 2500/250 = 10, leadTime = 5 → ROP = 50
50
+ expect(r.reorderPoint).toBeCloseTo(50, 4);
51
+ });
52
+
53
+ it('higher ordering cost → higher EOQ', () => {
54
+ const r1 = economicOrderQuantity(1000, 10, 2.5);
55
+ const r2 = economicOrderQuantity(1000, 50, 2.5);
56
+ expect(r2.eoq).toBeGreaterThan(r1.eoq);
57
+ });
58
+
59
+ it('throws for zero annual demand', () => {
60
+ expect(() => economicOrderQuantity(0, 10, 2.5)).toThrow();
61
+ });
62
+
63
+ it('throws for zero holding cost', () => {
64
+ expect(() => economicOrderQuantity(1000, 10, 0)).toThrow();
65
+ });
66
+ });
67
+
68
+ // ─── Price optimization ───────────────────────────────────────────────────────
69
+
70
+ describe('priceOptimization', () => {
71
+ /**
72
+ * Elastic demand: ε = -2 (elastic)
73
+ * Profit-maximizing price should be found via ternary search.
74
+ * With variable cost = 0: dπ/dP = Q(1 + 1/ε) = 0 → P* = P₀/(1+1/ε) ... but with variable cost = 0 and elasticity=-2, revenue is maximized at a price where MR=0.
75
+ */
76
+ it('elastic demand (ε=-2): revenue maximized by lower price', () => {
77
+ // With ε=-2, revenue decreasing above current price → optimal < current
78
+ const r = priceOptimization(100, 1000, -2);
79
+ // Optimal price should give higher revenue than current in elastic region
80
+ expect(r.optimalRevenue).toBeGreaterThanOrEqual(r.currentRevenue - 0.01);
81
+ });
82
+
83
+ it('optimalRevenue is positive and finite', () => {
84
+ // Solver maximizes PROFIT (not revenue); with variableCost=20, inelastic ε=-1.5
85
+ // raises price above $50 → higher margin but lower revenue. Revenue need not be ≥ current.
86
+ const r = priceOptimization(50, 500, -1.5, 20);
87
+ expect(r.optimalRevenue).toBeGreaterThan(0);
88
+ expect(Number.isFinite(r.optimalRevenue)).toBe(true);
89
+ expect(r.optimalPrice).toBeGreaterThan(0);
90
+ });
91
+
92
+ it('priceDemandCurve has 21 points', () => {
93
+ const r = priceOptimization(100, 500, -1.5);
94
+ expect(r.priceDemandCurve).toHaveLength(21);
95
+ });
96
+
97
+ it('demand decreases as price increases (law of demand)', () => {
98
+ const r = priceOptimization(100, 500, -1.5);
99
+ const curve = r.priceDemandCurve;
100
+ for (let i = 1; i < curve.length; i++) {
101
+ expect(curve[i].demand).toBeLessThanOrEqual(curve[i - 1].demand + 0.01);
102
+ }
103
+ });
104
+
105
+ it('throws for non-negative elasticity', () => {
106
+ expect(() => priceOptimization(100, 500, 0)).toThrow();
107
+ });
108
+
109
+ it('throws for zero current demand', () => {
110
+ expect(() => priceOptimization(100, 0, -1.5)).toThrow();
111
+ });
112
+ });
113
+
114
+ // ─── Markdown optimization ────────────────────────────────────────────────────
115
+
116
+ describe('markdownOptimization', () => {
117
+ it('schedule has same number of entries as markdownSteps', () => {
118
+ const r = markdownOptimization(100, 500, 90, 5, -2.0);
119
+ expect(r.schedule).toHaveLength(4); // default 4 steps
120
+ });
121
+
122
+ it('prices decrease with each markdown step', () => {
123
+ const r = markdownOptimization(100, 500, 90, 5, -2.0);
124
+ for (let i = 1; i < r.schedule.length; i++) {
125
+ expect(r.schedule[i].price).toBeLessThanOrEqual(r.schedule[i - 1].price);
126
+ }
127
+ });
128
+
129
+ it('expectedRevenue ≥ 0', () => {
130
+ const r = markdownOptimization(100, 500, 90, 5, -2.0);
131
+ expect(r.expectedRevenue).toBeGreaterThanOrEqual(0);
132
+ });
133
+
134
+ it('expectedUnsold ≥ 0', () => {
135
+ const r = markdownOptimization(100, 50, 30, 2, -3.0, [10]);
136
+ expect(r.expectedUnsold).toBeGreaterThanOrEqual(0);
137
+ });
138
+
139
+ it('throws for non-positive inventory', () => {
140
+ expect(() => markdownOptimization(100, 0, 90, 5, -2)).toThrow();
141
+ });
142
+ });
143
+
144
+ // ─── Customer Lifetime Value ──────────────────────────────────────────────────
145
+
146
+ describe('customerLifetimeValue', () => {
147
+ /**
148
+ * AOV=$100, freq=4/yr, lifespan=5yr, r=0.10
149
+ * Simple CLV = 100 × 4 × 5 = $2000
150
+ * Discounted CLV = Σ_{t=1}^{5} 400 / 1.1^t ≈ 400 × 3.791 ≈ $1516
151
+ */
152
+ it('simpleCLV = AOV × frequency × lifespan', () => {
153
+ const r = customerLifetimeValue(100, 4, 5, 0.10);
154
+ expect(r.simpleCLV).toBeCloseTo(2000, 4);
155
+ });
156
+
157
+ it('discountedCLV < simpleCLV for positive discount rate', () => {
158
+ const r = customerLifetimeValue(100, 4, 5, 0.10);
159
+ expect(r.discountedCLV).toBeLessThan(r.simpleCLV);
160
+ });
161
+
162
+ it('discountedCLV ≈ simpleCLV when rate = 0', () => {
163
+ const r = customerLifetimeValue(100, 4, 5, 0);
164
+ expect(r.discountedCLV).toBeCloseTo(r.simpleCLV, 0);
165
+ });
166
+
167
+ it('CLV:CAC ratio computed when CAC provided', () => {
168
+ const r = customerLifetimeValue(100, 4, 5, 0.10, 200);
169
+ expect(r.clvToCac).not.toBeNull();
170
+ expect(r.clvToCac!).toBeCloseTo(r.discountedCLV / 200, 4);
171
+ });
172
+
173
+ it('CLV:CAC null when CAC not provided', () => {
174
+ const r = customerLifetimeValue(100, 4, 5, 0.10);
175
+ expect(r.clvToCac).toBeNull();
176
+ });
177
+
178
+ it('throws for zero AOV', () => {
179
+ expect(() => customerLifetimeValue(0, 4, 5, 0.10)).toThrow();
180
+ });
181
+ });
182
+
183
+ // ─── Conversion funnel ────────────────────────────────────────────────────────
184
+
185
+ describe('conversionFunnelAnalysis', () => {
186
+ /**
187
+ * Funnel: Visit → Cart → Checkout → Purchase
188
+ * 10000 → 2000 → 500 → 250
189
+ * Step conversions: 20%, 25%, 50%
190
+ * Overall: 250/10000 = 2.5%
191
+ */
192
+ const stages = ['Visit', 'Cart', 'Checkout', 'Purchase'];
193
+ const counts = [10000, 2000, 500, 250];
194
+
195
+ it('step conversions correct', () => {
196
+ const r = conversionFunnelAnalysis(stages, counts, 75);
197
+ expect(r.stepConversions[0]).toBeCloseTo(0.20, 4);
198
+ expect(r.stepConversions[1]).toBeCloseTo(0.25, 4);
199
+ expect(r.stepConversions[2]).toBeCloseTo(0.50, 4);
200
+ });
201
+
202
+ it('overall conversion = final / first', () => {
203
+ const r = conversionFunnelAnalysis(stages, counts, 75);
204
+ expect(r.overallConversion).toBeCloseTo(0.025, 4);
205
+ });
206
+
207
+ it('sensitivity revenue is positive for each step', () => {
208
+ const r = conversionFunnelAnalysis(stages, counts, 75);
209
+ for (const s of r.sensitivityRevenue) expect(s).toBeGreaterThanOrEqual(0);
210
+ });
211
+
212
+ it('throws when stages.length ≠ counts.length', () => {
213
+ expect(() => conversionFunnelAnalysis(['A', 'B'], [100], 50)).toThrow();
214
+ });
215
+
216
+ it('throws for fewer than 2 stages', () => {
217
+ expect(() => conversionFunnelAnalysis(['A'], [100], 50)).toThrow();
218
+ });
219
+ });
220
+
221
+ // ─── ABC classification ───────────────────────────────────────────────────────
222
+
223
+ describe('abcClassification', () => {
224
+ const items = [
225
+ { id: 'SKU-1', annualVolume: 100, unitCost: 500 }, // $50k — A
226
+ { id: 'SKU-2', annualVolume: 500, unitCost: 20 }, // $10k — A/B
227
+ { id: 'SKU-3', annualVolume: 1000, unitCost: 5 }, // $5k — B
228
+ { id: 'SKU-4', annualVolume: 5000, unitCost: 0.5 }, // $2.5k — C
229
+ { id: 'SKU-5', annualVolume: 2000, unitCost: 0.2 }, // $400 — C
230
+ ];
231
+
232
+ it('class A item has highest annual value', () => {
233
+ const r = abcClassification(items);
234
+ expect(r.items[0].class).toBe('A');
235
+ expect(r.items[0].id).toBe('SKU-1');
236
+ });
237
+
238
+ it('all items are classified', () => {
239
+ const r = abcClassification(items);
240
+ expect(r.items).toHaveLength(items.length);
241
+ });
242
+
243
+ it('class A holds the largest share of total value (Pareto principle)', () => {
244
+ // With discrete items, class A may not hit exactly 80% — it holds up to 80% threshold
245
+ // SKU-1 ($50k) = 73.6% of total $67.9k — class A boundary stops adding items at 80%
246
+ const r = abcClassification(items);
247
+ expect(r.classValuePct.A).toBeGreaterThan(50);
248
+ expect(r.classValuePct.A + r.classValuePct.B + r.classValuePct.C).toBeCloseTo(100, 4);
249
+ });
250
+
251
+ it('cumulative % is monotonically increasing', () => {
252
+ const r = abcClassification(items);
253
+ for (let i = 1; i < r.items.length; i++) {
254
+ expect(r.items[i].cumulativePct).toBeGreaterThanOrEqual(r.items[i - 1].cumulativePct);
255
+ }
256
+ });
257
+
258
+ it('throws for empty items', () => {
259
+ expect(() => abcClassification([])).toThrow();
260
+ });
261
+ });
262
+
263
+ // ─── Inventory metrics ────────────────────────────────────────────────────────
264
+
265
+ describe('inventoryMetrics', () => {
266
+ /**
267
+ * COGS=$500k, avg inventory=$100k → turnover=5, DSI=73 days
268
+ */
269
+ it('inventory turnover = COGS / avgInventory', () => {
270
+ const r = inventoryMetrics(500_000, 100_000);
271
+ expect(r.inventoryTurnover).toBeCloseTo(5, 5);
272
+ });
273
+
274
+ it('DSI = 365 / turnover', () => {
275
+ const r = inventoryMetrics(500_000, 100_000);
276
+ expect(r.dsi).toBeCloseTo(365 / 5, 4);
277
+ });
278
+
279
+ it('withinTarget=true when DSI ≤ targetDsi', () => {
280
+ const r = inventoryMetrics(500_000, 100_000, 90);
281
+ expect(r.withinTarget).toBe(true);
282
+ });
283
+
284
+ it('withinTarget=false when DSI > targetDsi', () => {
285
+ const r = inventoryMetrics(100_000, 100_000, 30); // DSI=365 > 30
286
+ expect(r.withinTarget).toBe(false);
287
+ });
288
+
289
+ it('throws for zero COGS', () => {
290
+ expect(() => inventoryMetrics(0, 100_000)).toThrow();
291
+ });
292
+ });
293
+
294
+ // ─── Receipt ─────────────────────────────────────────────────────────────────
295
+
296
+ describe('buildRetailReceipt', () => {
297
+ it('produces receipt with plugin=retail-ecommerce and CAEL event', () => {
298
+ const eoq = economicOrderQuantity(1000, 10, 2.5);
299
+ const receipt = buildRetailReceipt({ eoq, converged: true });
300
+ expect(receipt.plugin).toBe('retail-ecommerce');
301
+ expect(receipt.cael.event).toBe('retail_ecommerce.retail_analysis');
302
+ expect(receipt.payloadHash).toBeTruthy();
303
+ });
304
+
305
+ it('accepted=true for healthy metrics', () => {
306
+ const clv = customerLifetimeValue(200, 5, 3, 0.10, 150); // CLV:CAC ≈ 3+
307
+ const receipt = buildRetailReceipt({ clv, converged: true });
308
+ expect(receipt.acceptance.accepted).toBe(true);
309
+ });
310
+
311
+ it('accepted=false when CLV:CAC < 3', () => {
312
+ const clv = customerLifetimeValue(50, 1, 1, 0.10, 500); // poor CLV:CAC
313
+ const receipt = buildRetailReceipt({ clv, converged: true });
314
+ if (clv.clvToCac! < 3) {
315
+ expect(receipt.acceptance.accepted).toBe(false);
316
+ expect(receipt.acceptance.violations.length).toBeGreaterThan(0);
317
+ }
318
+ });
319
+
320
+ it('uses provided runId', () => {
321
+ const receipt = buildRetailReceipt({ converged: true }, { runId: 'retail-run-42' });
322
+ expect(receipt.runId).toBe('retail-run-42');
323
+ });
324
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Integration proof: the retail-ecommerce `eoq` trait, once registered via the
3
+ * runtime's real `registerTrait` seam, is dispatched BY THE RUNTIME and runs
4
+ * the Harris-Wilson Economic Order Quantity solver — NOT called directly as a
5
+ * handler object (the thin convention every other trait test uses).
6
+ *
7
+ * Mirrors the energy-grid reference integration. Drives the real path:
8
+ * executeNode(orb) -> orb-executor -> applyDirectives ->
9
+ * traitHandlers.get('eoq').onAttach -> economicOrderQuantity.
10
+ * The negative control proves the registration is load-bearing (without it,
11
+ * the trait is a dead no-op — which is exactly the tier's status quo).
12
+ */
13
+ import { describe, it, expect } from 'vitest';
14
+ import { HoloScriptRuntime } from '@holoscript/core/runtime';
15
+ import { registerRetailEcommerceTraitHandlers } from '../runtime';
16
+
17
+ // Hand-checkable EOQ: D=1000, S=10, H=2.5 => sqrt(2*1000*10/2.5) = sqrt(8000) ≈ 89.4427.
18
+ const EOQ_CONFIG = {
19
+ annualDemand: 1000,
20
+ orderingCost: 10,
21
+ holdingCostPerUnit: 2.5,
22
+ };
23
+ const EXPECTED_EOQ = Math.sqrt((2 * 1000 * 10) / 2.5); // 89.44271909999159
24
+
25
+ function eoqOrb(config: Record<string, unknown>): unknown {
26
+ return {
27
+ type: 'orb',
28
+ name: 'retail',
29
+ properties: {},
30
+ methods: [],
31
+ position: [0, 0, 0],
32
+ hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
33
+ directives: [{ type: 'trait', name: 'eoq', config }],
34
+ };
35
+ }
36
+
37
+ /** Flush the runtime's async emit dispatch so `on` listeners have fired. */
38
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
39
+
40
+ describe('retail-ecommerce -> HoloScript runtime integration (eoq)', () => {
41
+ it('runtime dispatch runs the EOQ solver for a registered @eoq orb', async () => {
42
+ const runtime = new HoloScriptRuntime();
43
+ registerRetailEcommerceTraitHandlers(runtime);
44
+
45
+ const solved: Array<Record<string, unknown>> = [];
46
+ runtime.on('eoq_solved', (e: unknown) => {
47
+ solved.push(e as Record<string, unknown>);
48
+ });
49
+
50
+ await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
51
+ await flush();
52
+
53
+ expect(solved).toHaveLength(1);
54
+ const summary = solved[0];
55
+ // EOQ = sqrt(2*D*S/H) = sqrt(8000) ≈ 89.44.
56
+ expect(summary.eoq as number).toBeCloseTo(EXPECTED_EOQ, 5);
57
+ expect(summary.eoq as number).toBeCloseTo(89.4427, 4);
58
+ // ordersPerYear = D / EOQ = 1000 / 89.4427 ≈ 11.1803.
59
+ expect(summary.ordersPerYear as number).toBeCloseTo(1000 / EXPECTED_EOQ, 5);
60
+ expect(summary.annualDemand).toBe(1000);
61
+ });
62
+
63
+ it('NEGATIVE CONTROL: without registration the @eoq trait is a dead no-op', async () => {
64
+ const runtime = new HoloScriptRuntime(); // intentionally NOT registered
65
+ const solved: unknown[] = [];
66
+ runtime.on('eoq_solved', (e: unknown) => solved.push(e));
67
+
68
+ await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
69
+ await flush();
70
+
71
+ expect(solved).toHaveLength(0);
72
+ });
73
+
74
+ it('persists the solver result into durable runtime state on ATTACH', async () => {
75
+ const runtime = new HoloScriptRuntime();
76
+ registerRetailEcommerceTraitHandlers(runtime);
77
+
78
+ await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
79
+ await flush();
80
+
81
+ const state = runtime.getState() as Record<string, unknown>;
82
+ const persisted = state['eoq:retail'] as
83
+ | { eoq?: number; annualDemand?: number }
84
+ | undefined;
85
+ expect(persisted).toBeDefined();
86
+ expect(persisted?.eoq).toBeCloseTo(EXPECTED_EOQ, 5);
87
+ expect(persisted?.annualDemand).toBe(1000);
88
+ });
89
+
90
+ it('emits eoq_error (does not throw through the runtime) for missing inputs', async () => {
91
+ const runtime = new HoloScriptRuntime();
92
+ registerRetailEcommerceTraitHandlers(runtime);
93
+
94
+ const errors: Array<Record<string, unknown>> = [];
95
+ runtime.on('eoq_error', (e: unknown) => {
96
+ errors.push(e as Record<string, unknown>);
97
+ });
98
+
99
+ // holdingCostPerUnit omitted -> the handler emits eoq_error, never throws.
100
+ await runtime.executeNode(
101
+ eoqOrb({ annualDemand: 1000, orderingCost: 10 }) as never,
102
+ );
103
+ await flush();
104
+
105
+ expect(errors).toHaveLength(1);
106
+ expect(String(errors[0].error)).toContain('holdingCostPerUnit');
107
+ });
108
+ });
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { createCartHandler, type CartConfig, type CartItem, type CartState } from './traits/CartTrait';
2
+ export { createCheckoutHandler, type CheckoutConfig, type CheckoutStep, type PaymentMethod, type Address } from './traits/CheckoutTrait';
3
+ export { createProductCatalogHandler, type ProductCatalogConfig, type Product } from './traits/ProductCatalogTrait';
4
+ export { createShippingRateHandler, type ShippingRateConfig, type ShippingMethod } from './traits/ShippingRateTrait';
5
+ export { createReturnHandler, type ReturnConfig, type ReturnStatus } from './traits/ReturnTrait';
6
+ export * from './traits/types';
7
+
8
+ import { createCartHandler } from './traits/CartTrait';
9
+ import { createCheckoutHandler } from './traits/CheckoutTrait';
10
+ import { createProductCatalogHandler } from './traits/ProductCatalogTrait';
11
+ import { createShippingRateHandler } from './traits/ShippingRateTrait';
12
+ import { createReturnHandler } from './traits/ReturnTrait';
13
+
14
+ export * from './retailsolver';
15
+ export * from './runtime';
16
+
17
+ export const pluginMeta = { name: '@holoscript/plugin-retail-ecommerce', version: '1.0.0', traits: ['cart', 'checkout', 'product_catalog', 'shipping_rate', 'return', 'eoq', 'price_optimization', 'markdown', 'clv', 'conversion_funnel', 'abc_classification', 'inventory_metrics'] };
18
+ export const traitHandlers = [createCartHandler(), createCheckoutHandler(), createProductCatalogHandler(), createShippingRateHandler(), createReturnHandler()];
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Retail & e-commerce solvers — retail-ecommerce-plugin
3
+ *
4
+ * Implements:
5
+ * - EOQ (Economic Order Quantity) inventory optimization
6
+ * - Demand elasticity and price optimization
7
+ * - Markdown optimization (progressive discounting)
8
+ * - Customer Lifetime Value (CLV) estimation
9
+ * - Conversion funnel analysis
10
+ * - ABC inventory classification
11
+ * - Days sales of inventory (DSI) and inventory turnover
12
+ *
13
+ * References:
14
+ * - Harris FW (1913) Operations and Cost — original EOQ paper
15
+ * - Philips RL (2005) Pricing and Revenue Optimization — demand elasticity
16
+ * - Fader PS et al. (2005) J.Marketing Res. — Pareto/NBD CLV model
17
+ */
18
+
19
+ import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ export interface EOQResult {
24
+ /** Annual demand units */
25
+ annualDemand: number;
26
+ /** Ordering cost per order ($) */
27
+ orderingCost: number;
28
+ /** Holding cost per unit per year ($) */
29
+ holdingCostPerUnit: number;
30
+ /** Optimal order quantity units */
31
+ eoq: number;
32
+ /** Number of orders per year */
33
+ ordersPerYear: number;
34
+ /** Total annual inventory cost ($) */
35
+ totalAnnualCost: number;
36
+ /** Reorder point given lead time demand */
37
+ reorderPoint: number;
38
+ }
39
+
40
+ export interface PriceOptimizationResult {
41
+ /** Current price */
42
+ currentPrice: number;
43
+ /** Price elasticity of demand (negative) */
44
+ elasticity: number;
45
+ /** Profit-maximizing price */
46
+ optimalPrice: number;
47
+ /** Revenue at current price */
48
+ currentRevenue: number;
49
+ /** Revenue at optimal price */
50
+ optimalRevenue: number;
51
+ /** Revenue uplift % */
52
+ revenueUpliftPct: number;
53
+ /** Price-demand curve points */
54
+ priceDemandCurve: Array<{ price: number; demand: number; revenue: number }>;
55
+ }
56
+
57
+ export interface MarkdownResult {
58
+ /** Original price */
59
+ originalPrice: number;
60
+ /** Remaining inventory units */
61
+ inventory: number;
62
+ /** Days until season end */
63
+ daysRemaining: number;
64
+ /** Optimal markdown schedule */
65
+ schedule: Array<{ dayOfMarkdown: number; discountPct: number; price: number; expectedUnits: number }>;
66
+ /** Expected total revenue */
67
+ expectedRevenue: number;
68
+ /** Expected unsold units */
69
+ expectedUnsold: number;
70
+ }
71
+
72
+ export interface CLVResult {
73
+ /** Average purchase value */
74
+ avgOrderValue: number;
75
+ /** Purchase frequency per year */
76
+ purchaseFrequency: number;
77
+ /** Customer lifespan years */
78
+ lifespanYears: number;
79
+ /** Discount rate */
80
+ discountRate: number;
81
+ /** Simple CLV = AOV × frequency × lifespan */
82
+ simpleCLV: number;
83
+ /** Discounted CLV (NPV of future purchases) */
84
+ discountedCLV: number;
85
+ /** CLV-to-CAC ratio (if CAC provided) */
86
+ clvToCac: number | null;
87
+ }
88
+
89
+ export interface FunnelAnalysisResult {
90
+ stages: string[];
91
+ counts: number[];
92
+ /** Conversion rate stage-to-stage */
93
+ stepConversions: number[];
94
+ /** Overall funnel conversion rate */
95
+ overallConversion: number;
96
+ /** Revenue impact of improving each step by 10pp */
97
+ sensitivityRevenue: number[];
98
+ }
99
+
100
+ export interface ABCClassification {
101
+ items: Array<{
102
+ id: string;
103
+ annualValue: number;
104
+ cumulativePct: number;
105
+ class: 'A' | 'B' | 'C';
106
+ }>;
107
+ /** Count of A, B, C items */
108
+ classCounts: { A: number; B: number; C: number };
109
+ /** % of total value in each class */
110
+ classValuePct: { A: number; B: number; C: number };
111
+ }
112
+
113
+ export interface RetailReceiptOptions {
114
+ runId?: string;
115
+ }
116
+
117
+ // ─── EOQ ─────────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Classic Economic Order Quantity (Harris-Wilson formula).
121
+ * EOQ = √(2 × D × S / H)
122
+ * where D = annual demand, S = ordering cost, H = holding cost per unit/year.
123
+ */
124
+ export function economicOrderQuantity(
125
+ annualDemand: number,
126
+ orderingCost: number,
127
+ holdingCostPerUnit: number,
128
+ leadTimeDays = 0,
129
+ workingDaysPerYear = 250,
130
+ ): EOQResult {
131
+ if (annualDemand <= 0) throw new Error('annualDemand must be positive');
132
+ if (orderingCost <= 0) throw new Error('orderingCost must be positive');
133
+ if (holdingCostPerUnit <= 0) throw new Error('holdingCostPerUnit must be positive');
134
+
135
+ const eoq = Math.sqrt((2 * annualDemand * orderingCost) / holdingCostPerUnit);
136
+ const ordersPerYear = annualDemand / eoq;
137
+ const totalAnnualCost = (annualDemand / eoq) * orderingCost + (eoq / 2) * holdingCostPerUnit;
138
+ const dailyDemand = annualDemand / workingDaysPerYear;
139
+ const reorderPoint = dailyDemand * leadTimeDays;
140
+
141
+ return { annualDemand, orderingCost, holdingCostPerUnit, eoq, ordersPerYear, totalAnnualCost, reorderPoint };
142
+ }
143
+
144
+ // ─── Price optimization via demand elasticity ─────────────────────────────────
145
+
146
+ /**
147
+ * Optimize price using constant-elasticity demand model.
148
+ * Q(P) = Q₀ × (P/P₀)^ε where ε < 0 (price elasticity)
149
+ *
150
+ * Revenue R(P) = P × Q(P)
151
+ * Optimal (revenue-maximizing) price: P* = P₀ / (1 + 1/ε) [markup formula]
152
+ * For elastic demand (ε < -1): P* < P₀ (lower price increases revenue)
153
+ * For inelastic demand (-1 < ε < 0): P* > P₀
154
+ *
155
+ * Variable cost per unit optional for profit maximization.
156
+ */
157
+ export function priceOptimization(
158
+ currentPrice: number,
159
+ currentDemand: number,
160
+ elasticity: number,
161
+ variableCost = 0,
162
+ priceRange?: [number, number],
163
+ ): PriceOptimizationResult {
164
+ if (currentPrice <= 0) throw new Error('currentPrice must be positive');
165
+ if (currentDemand <= 0) throw new Error('currentDemand must be positive');
166
+ if (elasticity >= 0) throw new Error('elasticity must be negative (price-demand law)');
167
+
168
+ const demandAt = (p: number) => currentDemand * Math.pow(p / currentPrice, elasticity);
169
+ const revenueAt = (p: number) => p * demandAt(p);
170
+ const profitAt = (p: number) => (p - variableCost) * demandAt(p);
171
+
172
+ // Profit-maximizing price via ternary search
173
+ const pLo = priceRange ? priceRange[0] : variableCost * 1.01;
174
+ const pHi = priceRange ? priceRange[1] : currentPrice * 3;
175
+
176
+ let lo = Math.max(pLo, variableCost + 0.01), hi = pHi;
177
+ for (let i = 0; i < 100; i++) {
178
+ const m1 = lo + (hi - lo) / 3;
179
+ const m2 = hi - (hi - lo) / 3;
180
+ if (profitAt(m1) < profitAt(m2)) lo = m1; else hi = m2;
181
+ }
182
+ const optimalPrice = (lo + hi) / 2;
183
+
184
+ const currentRevenue = revenueAt(currentPrice);
185
+ const optimalRevenue = revenueAt(optimalPrice);
186
+ const revenueUpliftPct = ((optimalRevenue - currentRevenue) / currentRevenue) * 100;
187
+
188
+ // Price-demand curve: 20 points from 50% to 200% of current price
189
+ const priceDemandCurve = Array.from({ length: 21 }, (_, i) => {
190
+ const p = currentPrice * (0.5 + i * 0.075);
191
+ return { price: p, demand: demandAt(p), revenue: revenueAt(p) };
192
+ });
193
+
194
+ return { currentPrice, elasticity, optimalPrice, currentRevenue, optimalRevenue, revenueUpliftPct, priceDemandCurve };
195
+ }
196
+
197
+ // ─── Markdown optimization ────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Progressive markdown optimization using a greedy sell-through target approach.
201
+ * Demand increases with discount depth per price elasticity.
202
+ */
203
+ export function markdownOptimization(
204
+ originalPrice: number,
205
+ inventory: number,
206
+ daysRemaining: number,
207
+ baseDailySalesRate: number,
208
+ elasticity: number,
209
+ markdownSteps: number[] = [10, 20, 30, 50],
210
+ ): MarkdownResult {
211
+ if (originalPrice <= 0) throw new Error('originalPrice must be positive');
212
+ if (inventory <= 0) throw new Error('inventory must be positive');
213
+ if (daysRemaining < 1) throw new Error('daysRemaining must be ≥ 1');
214
+ if (elasticity >= 0) throw new Error('elasticity must be negative');
215
+
216
+ // Simple greedy: evenly space markdowns across remaining days
217
+ const stepSize = Math.floor(daysRemaining / (markdownSteps.length + 1));
218
+ let remaining = inventory;
219
+ let totalRevenue = 0;
220
+
221
+ const schedule: MarkdownResult['schedule'] = [];
222
+ let currentPrice = originalPrice;
223
+ let currentRate = baseDailySalesRate;
224
+ let prevDay = 0;
225
+
226
+ for (let si = 0; si < markdownSteps.length; si++) {
227
+ const dayOfMarkdown = (si + 1) * stepSize;
228
+ const discountPct = markdownSteps[si];
229
+ const newPrice = originalPrice * (1 - discountPct / 100);
230
+ // Demand boost from discount via elasticity
231
+ const newRate = baseDailySalesRate * Math.pow(1 - discountPct / 100, elasticity);
232
+
233
+ // Sell from prevDay to dayOfMarkdown at currentRate/price
234
+ const daysAtCurrentPrice = dayOfMarkdown - prevDay;
235
+ const unitsSold = Math.min(remaining, Math.round(currentRate * daysAtCurrentPrice));
236
+ totalRevenue += unitsSold * currentPrice;
237
+ remaining = Math.max(0, remaining - unitsSold);
238
+
239
+ schedule.push({ dayOfMarkdown, discountPct, price: newPrice, expectedUnits: Math.round(newRate * stepSize) });
240
+
241
+ currentPrice = newPrice;
242
+ currentRate = newRate;
243
+ prevDay = dayOfMarkdown;
244
+ }
245
+
246
+ // Final period after last markdown
247
+ const finalDays = daysRemaining - prevDay;
248
+ const finalUnits = Math.min(remaining, Math.round(currentRate * finalDays));
249
+ totalRevenue += finalUnits * currentPrice;
250
+ remaining = Math.max(0, remaining - finalUnits);
251
+
252
+ return { originalPrice, inventory, daysRemaining, schedule, expectedRevenue: totalRevenue, expectedUnsold: remaining };
253
+ }
254
+
255
+ // ─── Customer Lifetime Value ──────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Compute CLV using simple and discounted (NPV) formulas.
259
+ * Simple: CLV = AOV × frequency × lifespan
260
+ * Discounted: CLV = Σ_{t=1}^{L} AOV × freq / (1+r)^t
261
+ */
262
+ export function customerLifetimeValue(
263
+ avgOrderValue: number,
264
+ purchaseFrequency: number,
265
+ lifespanYears: number,
266
+ discountRate: number,
267
+ cac?: number,
268
+ ): CLVResult {
269
+ if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
270
+ if (purchaseFrequency <= 0) throw new Error('purchaseFrequency must be positive');
271
+ if (lifespanYears <= 0) throw new Error('lifespanYears must be positive');
272
+ if (discountRate < 0) throw new Error('discountRate must be non-negative');
273
+
274
+ const simpleCLV = avgOrderValue * purchaseFrequency * lifespanYears;
275
+
276
+ // Discounted: annual revenue / (1+r)^year, summed
277
+ let discountedCLV = 0;
278
+ const annualRevenue = avgOrderValue * purchaseFrequency;
279
+ for (let y = 1; y <= Math.ceil(lifespanYears); y++) {
280
+ const weight = Math.min(1, lifespanYears - (y - 1)); // partial last year
281
+ discountedCLV += (weight * annualRevenue) / Math.pow(1 + discountRate, y);
282
+ }
283
+
284
+ const clvToCac = cac != null && cac > 0 ? discountedCLV / cac : null;
285
+
286
+ return { avgOrderValue, purchaseFrequency, lifespanYears, discountRate, simpleCLV, discountedCLV, clvToCac };
287
+ }
288
+
289
+ // ─── Conversion funnel analysis ───────────────────────────────────────────────
290
+
291
+ /**
292
+ * Analyze a conversion funnel and compute revenue sensitivity.
293
+ */
294
+ export function conversionFunnelAnalysis(
295
+ stages: string[],
296
+ counts: number[],
297
+ avgOrderValue: number,
298
+ ): FunnelAnalysisResult {
299
+ if (stages.length !== counts.length) throw new Error('stages and counts must have same length');
300
+ if (stages.length < 2) throw new Error('Funnel needs at least 2 stages');
301
+ if (counts.some(c => c < 0)) throw new Error('counts must be non-negative');
302
+ if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
303
+
304
+ const stepConversions = counts.slice(1).map((c, i) => counts[i] > 0 ? c / counts[i] : 0);
305
+ const overallConversion = counts[0] > 0 ? counts[counts.length - 1] / counts[0] : 0;
306
+ const currentRevenue = counts[counts.length - 1] * avgOrderValue;
307
+
308
+ // Sensitivity: if step i conversion improves by 10pp, how much extra revenue?
309
+ const sensitivityRevenue = stepConversions.map((conv, i) => {
310
+ const newConv = Math.min(1, conv + 0.10);
311
+ const upliftFactor = newConv / (conv || 1e-9);
312
+ // Downstream: all subsequent stages are multiplied by this factor
313
+ let newFinal = counts[i + 1] * upliftFactor;
314
+ for (let j = i + 2; j < counts.length; j++) {
315
+ newFinal *= (counts[j - 1] > 0 ? counts[j] / counts[j - 1] : 1);
316
+ }
317
+ return (newFinal - counts[counts.length - 1]) * avgOrderValue;
318
+ });
319
+
320
+ return { stages, counts, stepConversions, overallConversion, sensitivityRevenue };
321
+ }
322
+
323
+ // ─── ABC inventory classification ─────────────────────────────────────────────
324
+
325
+ /**
326
+ * Classify inventory items into A/B/C tiers by annual value contribution.
327
+ * A: top 80% of value (~20% of items)
328
+ * B: next 15% of value
329
+ * C: remaining 5% of value
330
+ */
331
+ export function abcClassification(
332
+ items: Array<{ id: string; annualVolume: number; unitCost: number }>,
333
+ ): ABCClassification {
334
+ if (items.length === 0) throw new Error('No items provided');
335
+
336
+ const withValues = items.map(item => ({
337
+ id: item.id,
338
+ annualValue: item.annualVolume * item.unitCost,
339
+ })).sort((a, b) => b.annualValue - a.annualValue);
340
+
341
+ const totalValue = withValues.reduce((a, b) => a + b.annualValue, 0);
342
+ let cumulative = 0;
343
+ const classCounts = { A: 0, B: 0, C: 0 };
344
+ const classValue = { A: 0, B: 0, C: 0 };
345
+
346
+ const classified = withValues.map(item => {
347
+ cumulative += item.annualValue;
348
+ const cumulativePct = (cumulative / totalValue) * 100;
349
+ const cls: 'A' | 'B' | 'C' = cumulativePct <= 80 ? 'A' : cumulativePct <= 95 ? 'B' : 'C';
350
+ classCounts[cls]++;
351
+ classValue[cls] += item.annualValue;
352
+ return { ...item, cumulativePct, class: cls };
353
+ });
354
+
355
+ const classValuePct = {
356
+ A: (classValue.A / totalValue) * 100,
357
+ B: (classValue.B / totalValue) * 100,
358
+ C: (classValue.C / totalValue) * 100,
359
+ };
360
+
361
+ return { items: classified, classCounts, classValuePct };
362
+ }
363
+
364
+ // ─── Inventory turnover & DSI ─────────────────────────────────────────────────
365
+
366
+ export interface InventoryMetrics {
367
+ /** Cost of goods sold */
368
+ cogs: number;
369
+ /** Average inventory value */
370
+ avgInventory: number;
371
+ /** Inventory turnover ratio = COGS / avg inventory */
372
+ inventoryTurnover: number;
373
+ /** Days sales of inventory = 365 / turnover */
374
+ dsi: number;
375
+ /** Target DSI (if provided) */
376
+ targetDsi?: number;
377
+ /** Whether current DSI is within target */
378
+ withinTarget?: boolean;
379
+ }
380
+
381
+ export function inventoryMetrics(
382
+ cogs: number,
383
+ avgInventory: number,
384
+ targetDsi?: number,
385
+ ): InventoryMetrics {
386
+ if (cogs <= 0) throw new Error('COGS must be positive');
387
+ if (avgInventory <= 0) throw new Error('avgInventory must be positive');
388
+
389
+ const inventoryTurnover = cogs / avgInventory;
390
+ const dsi = 365 / inventoryTurnover;
391
+
392
+ return {
393
+ cogs, avgInventory, inventoryTurnover, dsi,
394
+ targetDsi,
395
+ withinTarget: targetDsi != null ? dsi <= targetDsi : undefined,
396
+ };
397
+ }
398
+
399
+ // ─── Receipt ──────────────────────────────────────────────────────────────────
400
+
401
+ export interface RetailAnalysisResult {
402
+ eoq?: EOQResult;
403
+ priceOpt?: PriceOptimizationResult;
404
+ markdown?: MarkdownResult;
405
+ clv?: CLVResult;
406
+ funnel?: FunnelAnalysisResult;
407
+ abc?: ABCClassification;
408
+ inventory?: InventoryMetrics;
409
+ converged: true;
410
+ }
411
+
412
+ export function buildRetailReceipt(
413
+ result: RetailAnalysisResult,
414
+ options?: RetailReceiptOptions,
415
+ ): DomainSimulationReceipt {
416
+ const violations: Array<{ criterion: string; message: string }> = [];
417
+
418
+ if (result.inventory?.withinTarget === false) {
419
+ violations.push({
420
+ criterion: 'dsi',
421
+ message: `DSI ${result.inventory.dsi.toFixed(1)} days exceeds target ${result.inventory.targetDsi} days`,
422
+ });
423
+ }
424
+ if (result.clv?.clvToCac != null && result.clv.clvToCac < 3) {
425
+ violations.push({
426
+ criterion: 'clv_to_cac',
427
+ message: `CLV:CAC ratio ${result.clv.clvToCac.toFixed(2)} is below recommended minimum of 3`,
428
+ });
429
+ }
430
+
431
+ return buildDomainSimulationReceipt({
432
+ plugin: 'retail-ecommerce',
433
+ pluginVersion: '1.0.0',
434
+ runId: options?.runId ?? `retail-${Date.now().toString(36)}`,
435
+ solverConfig: { solverType: 'retail-analytics', scale: 'store' },
436
+ resultSummary: {
437
+ eoq: result.eoq?.eoq ?? null,
438
+ optimalPrice: result.priceOpt?.optimalPrice ?? null,
439
+ revenueUpliftPct: result.priceOpt?.revenueUpliftPct ?? null,
440
+ clv: result.clv?.discountedCLV ?? null,
441
+ overallConversion: result.funnel?.overallConversion ?? null,
442
+ },
443
+ cael: { version: 'cael.v1', event: 'retail_ecommerce.retail_analysis', solverType: 'retail-ecommerce.analytics' },
444
+ acceptance: { accepted: violations.length === 0, violations },
445
+ });
446
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Runtime integration for @holoscript/plugin-retail-ecommerce.
3
+ *
4
+ * Bridges the previously dead-wired `eoq` trait into a behavioral
5
+ * TraitHandler that the HoloScript runtime actually dispatches
6
+ * (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
7
+ *
8
+ * Before this module the plugin declared trait NAMES only
9
+ * (pluginMeta.traits) and exported `economicOrderQuantity` plus the other
10
+ * retail solvers, but nothing invoked a solver THROUGH the runtime — the whole
11
+ * domain-plugin tier was built-but-dead-wired. This mirrors the energy-grid
12
+ * reference integration (`power_flow` -> `solveDCPowerFlow`): the `eoq` trait
13
+ * now runs the real Harris-Wilson Economic Order Quantity solver via the
14
+ * shared `registerPluginTraits` registrar. The remaining declared trait names
15
+ * follow the same registrar shape.
16
+ */
17
+ import { registerPluginTraits } from '@holoscript/core/runtime';
18
+ import { economicOrderQuantity, type EOQResult } from './retailsolver';
19
+
20
+ /** Stable id for this plugin, used by the runtime collision guard. */
21
+ export const RETAIL_ECOMMERCE_PLUGIN_ID = 'retail-ecommerce' as const;
22
+
23
+ /** Config carried by an orb's `@eoq` trait directive. */
24
+ export interface EoqTraitConfig {
25
+ /** Annual demand units. Required; absence emits `eoq_error`. */
26
+ annualDemand?: number;
27
+ /** Ordering cost per order ($). Required; absence emits `eoq_error`. */
28
+ orderingCost?: number;
29
+ /** Holding cost per unit per year ($). Required; absence emits `eoq_error`. */
30
+ holdingCostPerUnit?: number;
31
+ /** Optional lead time (days) used to compute the reorder point. */
32
+ leadTimeDays?: number;
33
+ /** Optional working days per year forwarded to `economicOrderQuantity`. */
34
+ workingDaysPerYear?: number;
35
+ }
36
+
37
+ /** Summary payload emitted on `eoq_solved`. */
38
+ export interface EoqSolvedEvent {
39
+ nodeId: string;
40
+ eoq: number;
41
+ ordersPerYear: number;
42
+ totalAnnualCost: number;
43
+ reorderPoint: number;
44
+ annualDemand: number;
45
+ }
46
+
47
+ /**
48
+ * Structural view of the runtime trait-handler contract. Matches
49
+ * `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
50
+ * actually uses (onAttach / onUpdate receive the node, the directive config,
51
+ * and a context exposing `emit`). Declared locally so the plugin stays
52
+ * decoupled from core's full trait surface.
53
+ */
54
+ export interface TraitDispatchContext {
55
+ emit: (event: string, payload?: unknown) => void;
56
+ setState?: (updates: Record<string, unknown>) => void;
57
+ }
58
+
59
+ export interface RuntimeTraitHandler {
60
+ name: string;
61
+ onAttach?: (node: unknown, config: EoqTraitConfig, context: TraitDispatchContext) => void;
62
+ onUpdate?: (
63
+ node: unknown,
64
+ config: EoqTraitConfig,
65
+ context: TraitDispatchContext,
66
+ delta: number,
67
+ ) => void;
68
+ }
69
+
70
+ interface EoqNode {
71
+ id?: string;
72
+ name?: string;
73
+ properties?: Record<string, unknown>;
74
+ __eoqResult?: EOQResult;
75
+ }
76
+
77
+ /** Solve EOQ from `config`, write the result onto the node, and emit. */
78
+ function solveOntoNode(
79
+ node: unknown,
80
+ config: EoqTraitConfig | undefined,
81
+ context: TraitDispatchContext,
82
+ ): void {
83
+ const carrier = node as EoqNode;
84
+ const nodeId = carrier.id ?? carrier.name ?? 'unknown';
85
+
86
+ const annualDemand = config?.annualDemand;
87
+ const orderingCost = config?.orderingCost;
88
+ const holdingCostPerUnit = config?.holdingCostPerUnit;
89
+
90
+ if (
91
+ annualDemand === undefined ||
92
+ orderingCost === undefined ||
93
+ holdingCostPerUnit === undefined
94
+ ) {
95
+ context.emit('eoq_error', {
96
+ nodeId,
97
+ error:
98
+ 'eoq trait requires config.annualDemand, config.orderingCost, and config.holdingCostPerUnit',
99
+ });
100
+ return;
101
+ }
102
+
103
+ try {
104
+ const result = economicOrderQuantity(
105
+ annualDemand,
106
+ orderingCost,
107
+ holdingCostPerUnit,
108
+ config?.leadTimeDays ?? 0,
109
+ config?.workingDaysPerYear ?? 250,
110
+ );
111
+ carrier.__eoqResult = result;
112
+ carrier.properties = {
113
+ ...(carrier.properties ?? {}),
114
+ eoq: result.eoq,
115
+ ordersPerYear: result.ordersPerYear,
116
+ totalAnnualCost: result.totalAnnualCost,
117
+ };
118
+ const summary: EoqSolvedEvent = {
119
+ nodeId,
120
+ eoq: result.eoq,
121
+ ordersPerYear: result.ordersPerYear,
122
+ totalAnnualCost: result.totalAnnualCost,
123
+ reorderPoint: result.reorderPoint,
124
+ annualDemand: result.annualDemand,
125
+ };
126
+ context.setState?.({ [`eoq:${nodeId}`]: summary });
127
+ context.emit('eoq_solved', summary);
128
+ } catch (error) {
129
+ context.emit('eoq_error', {
130
+ nodeId,
131
+ error: error instanceof Error ? error.message : String(error),
132
+ });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Behavioral handler for the retail-ecommerce `eoq` trait. Runs the
138
+ * deterministic Harris-Wilson Economic Order Quantity solver whenever an orb
139
+ * carrying the trait is attached (and on each per-frame update), writing the
140
+ * result onto the node and emitting `eoq_solved` / `eoq_error`.
141
+ */
142
+ export const eoqHandler: RuntimeTraitHandler = {
143
+ name: 'eoq',
144
+ onAttach: (node, config, context) => solveOntoNode(node, config, context),
145
+ onUpdate: (node, config, context) => solveOntoNode(node, config, context),
146
+ };
147
+
148
+ /** A runtime that can register behavioral trait handlers. */
149
+ export interface TraitRegistrar {
150
+ registerTrait(name: string, handler: unknown): void;
151
+ }
152
+
153
+ /**
154
+ * Register retail-ecommerce behavioral trait handlers into a runtime that
155
+ * exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
156
+ * HoloScriptRuntime. This is the consumption path the dead-wired tier was
157
+ * missing: after this call the runtime's directive dispatch (applyDirectives /
158
+ * updateTraits) will invoke the retail EOQ solver for `@eoq` orbs.
159
+ */
160
+ export function registerRetailEcommerceTraitHandlers(registrar: TraitRegistrar): void {
161
+ registerPluginTraits(registrar, RETAIL_ECOMMERCE_PLUGIN_ID, [eoqHandler]);
162
+ }
@@ -0,0 +1,42 @@
1
+ /** @cart Trait — Shopping cart management. @trait cart */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export interface CartItem { productId: string; quantity: number; price: number; variant?: string; name: string; }
5
+ export interface CartConfig { currency: string; items: CartItem[]; maxItems: number; taxRate: number; }
6
+ export interface CartState { items: CartItem[]; subtotal: number; tax: number; total: number; itemCount: number; }
7
+
8
+ const defaultConfig: CartConfig = { currency: 'USD', items: [], maxItems: 99, taxRate: 0 };
9
+
10
+ function computeTotals(items: CartItem[], taxRate: number): Pick<CartState, 'subtotal' | 'tax' | 'total' | 'itemCount'> {
11
+ const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
12
+ const tax = subtotal * taxRate;
13
+ return { subtotal, tax, total: subtotal + tax, itemCount: items.reduce((sum, i) => sum + i.quantity, 0) };
14
+ }
15
+
16
+ export function createCartHandler(): TraitHandler<CartConfig> {
17
+ return {
18
+ name: 'cart', defaultConfig,
19
+ onAttach(node: HSPlusNode, config: CartConfig, ctx: TraitContext) {
20
+ const totals = computeTotals(config.items, config.taxRate);
21
+ node.__cartState = { items: [...config.items], ...totals };
22
+ ctx.emit?.('cart:created', { itemCount: totals.itemCount });
23
+ },
24
+ onDetach(node: HSPlusNode, _c: CartConfig, ctx: TraitContext) { delete node.__cartState; ctx.emit?.('cart:cleared'); },
25
+ onUpdate() {},
26
+ onEvent(node: HSPlusNode, config: CartConfig, ctx: TraitContext, event: TraitEvent) {
27
+ const s = node.__cartState as CartState | undefined; if (!s) return;
28
+ if (event.type === 'cart:add_item') {
29
+ const item = event.payload as unknown as CartItem;
30
+ const existing = s.items.find(i => i.productId === item.productId && i.variant === item.variant);
31
+ if (existing) existing.quantity += item.quantity; else s.items.push({ ...item });
32
+ Object.assign(s, computeTotals(s.items, config.taxRate));
33
+ ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
34
+ }
35
+ if (event.type === 'cart:remove_item') {
36
+ s.items = s.items.filter(i => i.productId !== (event.payload?.productId as string));
37
+ Object.assign(s, computeTotals(s.items, config.taxRate));
38
+ ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
39
+ }
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,29 @@
1
+ /** @checkout Trait — Checkout flow management. @trait checkout */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type CheckoutStep = 'shipping' | 'payment' | 'review' | 'confirm' | 'complete';
5
+ export type PaymentMethod = 'credit_card' | 'debit_card' | 'paypal' | 'crypto' | 'bank_transfer' | 'apple_pay';
6
+ export interface Address { line1: string; line2?: string; city: string; state: string; postalCode: string; country: string; }
7
+ export interface CheckoutConfig { step: CheckoutStep; paymentMethod: PaymentMethod; shippingAddress: Address | null; billingAddress: Address | null; orderTotal: number; tax: number; }
8
+ export interface CheckoutState { currentStep: CheckoutStep; isProcessing: boolean; orderId: string | null; error: string | null; }
9
+
10
+ const defaultConfig: CheckoutConfig = { step: 'shipping', paymentMethod: 'credit_card', shippingAddress: null, billingAddress: null, orderTotal: 0, tax: 0 };
11
+
12
+ export function createCheckoutHandler(): TraitHandler<CheckoutConfig> {
13
+ return {
14
+ name: 'checkout', defaultConfig,
15
+ onAttach(node: HSPlusNode, config: CheckoutConfig, ctx: TraitContext) { node.__checkoutState = { currentStep: config.step, isProcessing: false, orderId: null, error: null }; ctx.emit?.('checkout:started'); },
16
+ onDetach(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext) { delete node.__checkoutState; ctx.emit?.('checkout:abandoned'); },
17
+ onUpdate() {},
18
+ onEvent(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext, event: TraitEvent) {
19
+ const s = node.__checkoutState as CheckoutState | undefined; if (!s) return;
20
+ if (event.type === 'checkout:next_step') {
21
+ const steps: CheckoutStep[] = ['shipping', 'payment', 'review', 'confirm', 'complete'];
22
+ const idx = steps.indexOf(s.currentStep);
23
+ if (idx < steps.length - 1) { s.currentStep = steps[idx + 1]; ctx.emit?.('checkout:step_changed', { step: s.currentStep }); }
24
+ }
25
+ if (event.type === 'checkout:process_payment') { s.isProcessing = true; ctx.emit?.('checkout:processing'); }
26
+ if (event.type === 'checkout:complete') { s.currentStep = 'complete'; s.orderId = event.payload?.orderId as string; ctx.emit?.('checkout:completed', { orderId: s.orderId }); }
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,28 @@
1
+ /** @product_catalog Trait — Product catalog with search and filtering. @trait product_catalog */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export interface Product { id: string; name: string; price: number; category: string; brand?: string; inStock: boolean; imageUrl?: string; }
5
+ export interface ProductCatalogConfig { products: Product[]; categories: string[]; sortBy: 'price_asc' | 'price_desc' | 'name' | 'newest'; pageSize: number; }
6
+
7
+ const defaultConfig: ProductCatalogConfig = { products: [], categories: [], sortBy: 'name', pageSize: 20 };
8
+
9
+ export function createProductCatalogHandler(): TraitHandler<ProductCatalogConfig> {
10
+ return {
11
+ name: 'product_catalog', defaultConfig,
12
+ onAttach(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext) { node.__catalogState = { currentPage: 0, filteredCount: config.products.length, activeFilters: {} }; ctx.emit?.('catalog:loaded', { productCount: config.products.length }); },
13
+ onDetach(node: HSPlusNode, _c: ProductCatalogConfig, ctx: TraitContext) { delete node.__catalogState; ctx.emit?.('catalog:unloaded'); },
14
+ onUpdate() {},
15
+ onEvent(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext, event: TraitEvent) {
16
+ if (event.type === 'catalog:search') {
17
+ const query = (event.payload?.query as string || '').toLowerCase();
18
+ const results = config.products.filter(p => p.name.toLowerCase().includes(query));
19
+ ctx.emit?.('catalog:results', { count: results.length, products: results.slice(0, config.pageSize) });
20
+ }
21
+ if (event.type === 'catalog:filter') {
22
+ const category = event.payload?.category as string;
23
+ const results = category ? config.products.filter(p => p.category === category) : config.products;
24
+ ctx.emit?.('catalog:filtered', { count: results.length, category });
25
+ }
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,23 @@
1
+ /** @return Trait — Return and refund management. @trait return */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type ReturnStatus = 'requested' | 'approved' | 'shipped' | 'received' | 'refunded' | 'denied';
5
+ export interface ReturnConfig { orderId: string; reason: string; status: ReturnStatus; refundAmount: number; returnWindowDays: number; }
6
+
7
+ const defaultConfig: ReturnConfig = { orderId: '', reason: '', status: 'requested', refundAmount: 0, returnWindowDays: 30 };
8
+
9
+ export function createReturnHandler(): TraitHandler<ReturnConfig> {
10
+ return {
11
+ name: 'return', defaultConfig,
12
+ onAttach(node: HSPlusNode, config: ReturnConfig, ctx: TraitContext) { node.__returnState = { ...config, requestedAt: Date.now() }; ctx.emit?.('return:created', { orderId: config.orderId }); },
13
+ onDetach(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext) { delete node.__returnState; ctx.emit?.('return:cancelled'); },
14
+ onUpdate() {},
15
+ onEvent(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext, event: TraitEvent) {
16
+ const s = node.__returnState as Record<string, unknown> | undefined; if (!s) return;
17
+ if (event.type === 'return:approve') { s.status = 'approved'; ctx.emit?.('return:approved'); }
18
+ if (event.type === 'return:ship') { s.status = 'shipped'; ctx.emit?.('return:shipped'); }
19
+ if (event.type === 'return:receive') { s.status = 'received'; ctx.emit?.('return:received'); }
20
+ if (event.type === 'return:refund') { s.status = 'refunded'; ctx.emit?.('return:refunded', { amount: s.refundAmount }); }
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,19 @@
1
+ /** @shipping_rate Trait — Shipping cost calculation. @trait shipping_rate */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type ShippingMethod = 'standard' | 'express' | 'overnight' | 'freight' | 'pickup';
5
+ export interface ShippingRateConfig { carrier: string; method: ShippingMethod; weightKg: number; dimensions: { lengthCm: number; widthCm: number; heightCm: number; }; origin: string; destination: string; rate: number; estimatedDays: number; }
6
+
7
+ const defaultConfig: ShippingRateConfig = { carrier: '', method: 'standard', weightKg: 0, dimensions: { lengthCm: 0, widthCm: 0, heightCm: 0 }, origin: '', destination: '', rate: 0, estimatedDays: 5 };
8
+
9
+ export function createShippingRateHandler(): TraitHandler<ShippingRateConfig> {
10
+ return {
11
+ name: 'shipping_rate', defaultConfig,
12
+ onAttach(node: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext) { node.__shippingState = { calculatedRate: config.rate, carrier: config.carrier }; ctx.emit?.('shipping:rate_set', { rate: config.rate, method: config.method }); },
13
+ onDetach(node: HSPlusNode, _c: ShippingRateConfig, ctx: TraitContext) { delete node.__shippingState; ctx.emit?.('shipping:cleared'); },
14
+ onUpdate() {},
15
+ onEvent(_n: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext, event: TraitEvent) {
16
+ if (event.type === 'shipping:calculate') { ctx.emit?.('shipping:calculated', { rate: config.rate, estimatedDays: config.estimatedDays, carrier: config.carrier }); }
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,4 @@
1
+ export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
2
+ export interface TraitContext { emit?: (event: string, payload?: unknown) => void; getState?: () => Record<string, unknown>; setState?: (updates: Record<string, unknown>) => void; [key: string]: unknown; }
3
+ export interface TraitEvent { type: string; source?: string; payload?: Record<string, unknown>; [key: string]: unknown; }
4
+ export interface TraitHandler<TConfig = unknown> { name: string; defaultConfig: TConfig; onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void; onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void; }
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'path';
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ '@holoscript/engine': resolve(__dirname, '../../engine/src'),
8
+ '@holoscript/core': resolve(__dirname, '../../core/src'),
9
+ },
10
+ },
11
+ test: {
12
+ globals: true,
13
+ environment: 'node',
14
+ include: ['src/**/*.test.ts'],
15
+ passWithNoTests: true,
16
+ },
17
+ });