@holoscript/plugin-travel-hospitality 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-travel-hospitality
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,13 @@
1
+ {
2
+ "name": "@holoscript/plugin-travel-hospitality",
3
+ "version": "2.0.1",
4
+ "main": "src/index.ts",
5
+ "peerDependencies": {
6
+ "@holoscript/core": "8.0.6"
7
+ },
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "test": "vitest run --passWithNoTests",
11
+ "test:coverage": "vitest run --coverage --passWithNoTests"
12
+ }
13
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Revenue management tests — travel-hospitality-plugin
3
+ *
4
+ * Reference values verified against:
5
+ * - Belobaba PP (1987) MIT thesis — EMSR benchmarks
6
+ * - STR (Smith Travel Research) RevPAR methodology
7
+ * - Cross RG (1997) Revenue Management — overbooking models
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import {
12
+ emsrYieldManagement,
13
+ revparAnalysis,
14
+ overbookingOptimization,
15
+ groupDisplacementAnalysis,
16
+ hotelDemandForecast,
17
+ buildTravelReceipt,
18
+ type RateClass,
19
+ } from '../revenuemanagement';
20
+
21
+ // ─── EMSR yield management ────────────────────────────────────────────────────
22
+
23
+ describe('emsrYieldManagement', () => {
24
+ /**
25
+ * Classic 2-class example:
26
+ * Class 1 (high): ADR=$200, demand=80±20
27
+ * Class 2 (low): ADR=$100, demand=120±30
28
+ * Capacity: 150 rooms
29
+ *
30
+ * By Littlewood: P(D1 ≥ protection) = r_low/r_high = 100/200 = 0.5
31
+ * At 50th percentile of N(80,20): protection ≈ 80 rooms
32
+ */
33
+ const twoClass: RateClass[] = [
34
+ { id: 'BAR', adr: 200, expectedDemand: 80, demandStdDev: 20 },
35
+ { id: 'Discount', adr: 100, expectedDemand: 120, demandStdDev: 30 },
36
+ ];
37
+
38
+ it('returns protection and booking limits', () => {
39
+ const r = emsrYieldManagement(150, twoClass);
40
+ expect(r.protectionLevels).toHaveLength(2);
41
+ expect(r.bookingLimits).toHaveLength(2);
42
+ });
43
+
44
+ it('high-ADR class has more protection than low-ADR class', () => {
45
+ const r = emsrYieldManagement(150, twoClass);
46
+ // Class sorted [high→low ADR]: protectionLevels[0] = protect high class
47
+ expect(r.protectionLevels[0]).toBeGreaterThanOrEqual(0);
48
+ });
49
+
50
+ it('rate classes are sorted high→low ADR in output', () => {
51
+ const r = emsrYieldManagement(150, twoClass);
52
+ for (let i = 1; i < r.rateClasses.length; i++) {
53
+ expect(r.rateClasses[i].adr).toBeLessThanOrEqual(r.rateClasses[i - 1].adr);
54
+ }
55
+ });
56
+
57
+ it('expectedRevenue is positive', () => {
58
+ const r = emsrYieldManagement(150, twoClass);
59
+ expect(r.expectedRevenue).toBeGreaterThan(0);
60
+ });
61
+
62
+ it('booking limits are non-negative', () => {
63
+ const r = emsrYieldManagement(150, twoClass);
64
+ for (const bl of r.bookingLimits) expect(bl).toBeGreaterThanOrEqual(0);
65
+ });
66
+
67
+ it('3-class EMSR returns correct array lengths', () => {
68
+ const threeClass: RateClass[] = [
69
+ { id: 'Suite', adr: 400, expectedDemand: 20, demandStdDev: 8 },
70
+ { id: 'Standard', adr: 200, expectedDemand: 60, demandStdDev: 15 },
71
+ { id: 'Budget', adr: 100, expectedDemand: 100, demandStdDev: 25 },
72
+ ];
73
+ const r = emsrYieldManagement(200, threeClass);
74
+ expect(r.protectionLevels).toHaveLength(3);
75
+ expect(r.bookingLimits).toHaveLength(3);
76
+ });
77
+
78
+ it('throws for capacity ≤ 0', () => {
79
+ expect(() => emsrYieldManagement(0, twoClass)).toThrow();
80
+ });
81
+
82
+ it('throws for empty rate classes', () => {
83
+ expect(() => emsrYieldManagement(150, [])).toThrow();
84
+ });
85
+ });
86
+
87
+ // ─── RevPAR analysis ─────────────────────────────────────────────────────────
88
+
89
+ describe('revparAnalysis', () => {
90
+ /**
91
+ * 200 rooms, 160 occupied, revenue=$24,000
92
+ * Occupancy = 80%, ADR = 24000/160 = $150, RevPAR = 150 × 0.80 = $120
93
+ */
94
+ it('occupancy = occupied / available', () => {
95
+ const r = revparAnalysis(200, 160, 24_000);
96
+ expect(r.occupancyRate).toBeCloseTo(0.80, 5);
97
+ });
98
+
99
+ it('ADR = revenue / occupied rooms', () => {
100
+ const r = revparAnalysis(200, 160, 24_000);
101
+ expect(r.adr).toBeCloseTo(150, 3);
102
+ });
103
+
104
+ it('RevPAR = ADR × occupancy', () => {
105
+ const r = revparAnalysis(200, 160, 24_000);
106
+ expect(r.revpar).toBeCloseTo(r.adr * r.occupancyRate, 5);
107
+ });
108
+
109
+ it('full occupancy → RevPAR = ADR', () => {
110
+ const r = revparAnalysis(100, 100, 15_000);
111
+ expect(r.revpar).toBeCloseTo(r.adr, 5);
112
+ });
113
+
114
+ it('zero revenue → ADR = 0', () => {
115
+ const r = revparAnalysis(100, 0, 0);
116
+ expect(r.adr).toBe(0);
117
+ expect(r.revpar).toBe(0);
118
+ });
119
+
120
+ it('throws when occupiedRooms > availableRooms', () => {
121
+ expect(() => revparAnalysis(100, 110, 10_000)).toThrow();
122
+ });
123
+
124
+ it('throws for zero available rooms', () => {
125
+ expect(() => revparAnalysis(0, 0, 0)).toThrow();
126
+ });
127
+ });
128
+
129
+ // ─── Overbooking optimization ─────────────────────────────────────────────────
130
+
131
+ describe('overbookingOptimization', () => {
132
+ /**
133
+ * Capacity=100, noShowRate=0.10, ADR=$150, walkCost=$300
134
+ * Critical ratio = 150/(150+300) = 1/3 — expect some overbooking to be optimal
135
+ */
136
+ it('overbooking level ≥ 0', () => {
137
+ const r = overbookingOptimization(100, 0.10, 150, 300, 95);
138
+ expect(r.overbook).toBeGreaterThanOrEqual(0);
139
+ });
140
+
141
+ it('authorized bookings = capacity + overbook', () => {
142
+ const r = overbookingOptimization(100, 0.10, 150, 300, 95);
143
+ expect(r.authorizedBookings).toBe(r.capacity + r.overbook);
144
+ });
145
+
146
+ it('higher walk cost → lower overbooking', () => {
147
+ const r1 = overbookingOptimization(100, 0.10, 150, 100, 90); // low walk cost
148
+ const r2 = overbookingOptimization(100, 0.10, 150, 1000, 90); // high walk cost
149
+ expect(r2.overbook).toBeLessThanOrEqual(r1.overbook);
150
+ });
151
+
152
+ it('expectedWalkCost ≥ 0', () => {
153
+ const r = overbookingOptimization(100, 0.10, 150, 300, 95);
154
+ expect(r.expectedWalkCost).toBeGreaterThanOrEqual(0);
155
+ });
156
+
157
+ it('zero no-show rate → no overbooking optimal', () => {
158
+ const r = overbookingOptimization(100, 0, 150, 300, 90);
159
+ expect(r.overbook).toBe(0);
160
+ });
161
+
162
+ it('throws for noShowRate > 1', () => {
163
+ expect(() => overbookingOptimization(100, 1.5, 150, 300, 90)).toThrow();
164
+ });
165
+ });
166
+
167
+ // ─── Group displacement ───────────────────────────────────────────────────────
168
+
169
+ describe('groupDisplacementAnalysis', () => {
170
+ /**
171
+ * Group ADR=$120, 50 room-nights; Transient ADR=$180, prob=1.0
172
+ * Displacement cost = (180-120) × 50 = $3000
173
+ * Net value = 120×50 − 180×50 = $6000 − $9000 = −$3000 (unprofitable)
174
+ */
175
+ it('displacement cost = (transientADR - groupADR) × roomNights × prob', () => {
176
+ const r = groupDisplacementAnalysis(120, 50, 180, 1.0);
177
+ expect(r.displacementCost).toBeCloseTo((180 - 120) * 50, 4);
178
+ });
179
+
180
+ it('group with ADR > transient is profitable', () => {
181
+ const r = groupDisplacementAnalysis(200, 50, 150, 1.0);
182
+ expect(r.profitable).toBe(true);
183
+ expect(r.netValue).toBeGreaterThan(0);
184
+ });
185
+
186
+ it('group with ADR < transient at full demand probability is unprofitable', () => {
187
+ const r = groupDisplacementAnalysis(120, 50, 180, 1.0);
188
+ expect(r.profitable).toBe(false);
189
+ expect(r.netValue).toBeLessThan(0);
190
+ });
191
+
192
+ it('low transient demand probability → group becomes profitable', () => {
193
+ // If transient demand prob is very low, displacement cost low → group profitable
194
+ const r = groupDisplacementAnalysis(120, 50, 180, 0.1);
195
+ expect(r.profitable).toBe(true);
196
+ });
197
+
198
+ it('throws for zero groupADR', () => {
199
+ expect(() => groupDisplacementAnalysis(0, 50, 150, 1.0)).toThrow();
200
+ });
201
+ });
202
+
203
+ // ─── Hotel demand forecast ────────────────────────────────────────────────────
204
+
205
+ describe('hotelDemandForecast', () => {
206
+ // 28 days of occupancy data with weekend peaks
207
+ const historical = Array.from({ length: 28 }, (_, i) => {
208
+ const dow = i % 7;
209
+ return 80 + (dow === 5 || dow === 6 ? 15 : 0) + Math.round(Math.random() * 5);
210
+ });
211
+
212
+ it('forecast length = forecastDays', () => {
213
+ const r = hotelDemandForecast(historical, 7);
214
+ expect(r.forecast).toHaveLength(7);
215
+ });
216
+
217
+ it('seasonal indices have 7 elements (one per day of week)', () => {
218
+ const r = hotelDemandForecast(historical, 7);
219
+ expect(r.seasonalIndices).toHaveLength(7);
220
+ });
221
+
222
+ it('RMSE is non-negative', () => {
223
+ const r = hotelDemandForecast(historical, 7);
224
+ expect(r.rmse).toBeGreaterThanOrEqual(0);
225
+ });
226
+
227
+ it('forecast values are non-negative', () => {
228
+ const r = hotelDemandForecast(historical, 14);
229
+ for (const f of r.forecast) expect(f).toBeGreaterThanOrEqual(0);
230
+ });
231
+
232
+ it('weekend indices > weekday indices (reflects weekend demand spike)', () => {
233
+ const r = hotelDemandForecast(historical, 7);
234
+ const weekdayAvg = (r.seasonalIndices[0] + r.seasonalIndices[1] + r.seasonalIndices[2] +
235
+ r.seasonalIndices[3] + r.seasonalIndices[4]) / 5;
236
+ const weekendAvg = (r.seasonalIndices[5] + r.seasonalIndices[6]) / 2;
237
+ expect(weekendAvg).toBeGreaterThan(weekdayAvg);
238
+ });
239
+
240
+ it('throws for fewer than 14 days of history', () => {
241
+ expect(() => hotelDemandForecast([80, 90, 100], 7)).toThrow();
242
+ });
243
+
244
+ it('throws for zero forecastDays', () => {
245
+ expect(() => hotelDemandForecast(historical, 0)).toThrow();
246
+ });
247
+ });
248
+
249
+ // ─── Receipt ─────────────────────────────────────────────────────────────────
250
+
251
+ describe('buildTravelReceipt', () => {
252
+ it('produces receipt with plugin=travel-hospitality and CAEL event', () => {
253
+ const revpar = revparAnalysis(200, 160, 24_000);
254
+ const receipt = buildTravelReceipt({ revpar, converged: true });
255
+ expect(receipt.plugin).toBe('travel-hospitality');
256
+ expect(receipt.cael.event).toBe('travel_hospitality.revenue_management');
257
+ expect(receipt.payloadHash).toBeTruthy();
258
+ });
259
+
260
+ it('accepted=true for healthy 80% occupancy', () => {
261
+ const revpar = revparAnalysis(200, 160, 24_000); // 80% occupancy
262
+ const receipt = buildTravelReceipt({ revpar, converged: true });
263
+ expect(receipt.acceptance.accepted).toBe(true);
264
+ });
265
+
266
+ it('accepted=false for low occupancy < 40%', () => {
267
+ const revpar = revparAnalysis(200, 70, 7_000); // 35% occupancy
268
+ const receipt = buildTravelReceipt({ revpar, converged: true });
269
+ expect(receipt.acceptance.accepted).toBe(false);
270
+ expect(receipt.acceptance.violations.length).toBeGreaterThan(0);
271
+ });
272
+
273
+ it('accepted=false for unprofitable group booking', () => {
274
+ const groupDisplacement = groupDisplacementAnalysis(120, 50, 180, 1.0);
275
+ const receipt = buildTravelReceipt({ groupDisplacement, converged: true });
276
+ expect(receipt.acceptance.accepted).toBe(false);
277
+ });
278
+
279
+ it('uses provided runId', () => {
280
+ const receipt = buildTravelReceipt({ converged: true }, { runId: 'travel-run-77' });
281
+ expect(receipt.runId).toBe('travel-run-77');
282
+ });
283
+ });
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { createReservationHandler, type ReservationConfig, type ReservationStatus } from './traits/ReservationTrait';
2
+ export { createItineraryHandler, type ItineraryConfig, type ItineraryItem } from './traits/ItineraryTrait';
3
+ export { createRateManagementHandler, type RateManagementConfig, type RatePeriod } from './traits/RateManagementTrait';
4
+ export * from './traits/types';
5
+
6
+ import { createReservationHandler } from './traits/ReservationTrait';
7
+ import { createItineraryHandler } from './traits/ItineraryTrait';
8
+ import { createRateManagementHandler } from './traits/RateManagementTrait';
9
+
10
+ export * from './revenuemanagement';
11
+
12
+ export const pluginMeta = { name: '@holoscript/plugin-travel-hospitality', version: '1.0.0', traits: ['reservation', 'itinerary', 'rate_management', 'emsr_yield', 'revpar', 'overbooking', 'group_displacement', 'demand_forecast'] };
13
+ export const traitHandlers = [createReservationHandler(), createItineraryHandler(), createRateManagementHandler()];
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Revenue management solvers — travel-hospitality-plugin
3
+ *
4
+ * Implements:
5
+ * - Hotel yield management (EMSR-b overbooking model)
6
+ * - Dynamic pricing (bid-price control)
7
+ * - RevPAR / ADR / Occupancy analytics
8
+ * - Demand forecasting (exponential smoothing with seasonality)
9
+ * - Overbooking optimization (walk cost vs denied revenue trade-off)
10
+ * - Displacement cost calculation for group bookings
11
+ *
12
+ * References:
13
+ * - Littlewood K (1972) 12th AGIFORS Symposium — original yield management
14
+ * - Belobaba PP (1987) MIT thesis — EMSR
15
+ * - Cross RG (1997) Revenue Management — practical implementation
16
+ */
17
+
18
+ import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
19
+
20
+ // ─── Types ────────────────────────────────────────────────────────────────────
21
+
22
+ export interface RateClass {
23
+ /** Rate class identifier */
24
+ id: string;
25
+ /** Average daily rate for this class ($) */
26
+ adr: number;
27
+ /** Expected demand (units) */
28
+ expectedDemand: number;
29
+ /** Demand standard deviation */
30
+ demandStdDev: number;
31
+ }
32
+
33
+ export interface EMSRResult {
34
+ /** Protection levels per class (units to hold for higher classes) */
35
+ protectionLevels: number[];
36
+ /** Booking limits per class (open capacity for each class) */
37
+ bookingLimits: number[];
38
+ /** Expected revenue at optimal allocation */
39
+ expectedRevenue: number;
40
+ /** Rate classes (sorted high→low ADR) */
41
+ rateClasses: RateClass[];
42
+ }
43
+
44
+ export interface RevPARResult {
45
+ /** Rooms available */
46
+ availableRooms: number;
47
+ /** Rooms occupied */
48
+ occupiedRooms: number;
49
+ /** Occupancy rate [0,1] */
50
+ occupancyRate: number;
51
+ /** Average Daily Rate ($) */
52
+ adr: number;
53
+ /** Revenue Per Available Room = ADR × occupancy */
54
+ revpar: number;
55
+ /** Total room revenue ($) */
56
+ totalRevenue: number;
57
+ }
58
+
59
+ export interface OverbookingResult {
60
+ /** Total capacity */
61
+ capacity: number;
62
+ /** Optimal overbooking level (rooms above capacity) */
63
+ overbook: number;
64
+ /** Authorized bookings to accept */
65
+ authorizedBookings: number;
66
+ /** Expected no-show rate */
67
+ noShowRate: number;
68
+ /** Expected walk cost per displaced guest ($) */
69
+ walkCostPerGuest: number;
70
+ /** Expected total walk cost */
71
+ expectedWalkCost: number;
72
+ /** Expected revenue gain from overbooking vs no overbooking */
73
+ expectedRevenueGain: number;
74
+ }
75
+
76
+ export interface GroupDisplacementResult {
77
+ /** Group ADR */
78
+ groupAdr: number;
79
+ /** Group room-nights */
80
+ groupRoomNights: number;
81
+ /** Estimated transient ADR displaced */
82
+ transientAdr: number;
83
+ /** Displacement cost = (transientAdr - groupAdr) × roomNights */
84
+ displacementCost: number;
85
+ /** Net value: group revenue − displacement cost */
86
+ netValue: number;
87
+ /** Whether group booking is profitable */
88
+ profitable: boolean;
89
+ }
90
+
91
+ export interface HotelForecastResult {
92
+ historical: number[];
93
+ forecast: number[];
94
+ /** Seasonal indices (7-day weekly pattern) */
95
+ seasonalIndices: number[];
96
+ rmse: number;
97
+ }
98
+
99
+ export interface RevenueReceiptOptions {
100
+ runId?: string;
101
+ }
102
+
103
+ // ─── EMSR-b yield management ──────────────────────────────────────────────────
104
+
105
+ /** Normal CDF approximation (Abramowitz & Stegun) */
106
+ function normalCDF(x: number): number {
107
+ if (x < -6) return 0;
108
+ if (x > 6) return 1;
109
+ const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
110
+ const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
111
+ const sign = x >= 0 ? 1 : -1;
112
+ const absX = Math.abs(x);
113
+ const t = 1 / (1 + p * absX);
114
+ const poly = ((((a5 * t + a4) * t) + a3) * t + a2) * t + a1;
115
+ const erfVal = 1 - poly * t * Math.exp(-absX * absX);
116
+ return 0.5 * (1 + sign * erfVal);
117
+ }
118
+
119
+ /** Littlewood's rule: P(demand ≥ x) ≥ r_low/r_high → protect x seats for high class */
120
+ function littlewoodProtection(
121
+ rLow: number,
122
+ rHigh: number,
123
+ mu: number,
124
+ sigma: number,
125
+ ): number {
126
+ // Find x such that P(D_high ≥ x) = r_low / r_high
127
+ const threshold = rLow / rHigh;
128
+ // P(D ≥ x) = 1 - Φ((x - μ)/σ)
129
+ // 1 - Φ(z) = threshold → z = Φ⁻¹(1 - threshold)
130
+ // Approximate Φ⁻¹ via rational approximation
131
+ const p = 1 - threshold;
132
+ const z = normInvApprox(p);
133
+ return Math.max(0, Math.round(mu + sigma * z));
134
+ }
135
+
136
+ /** Approximate normal quantile (Peter Acklam's approximation) */
137
+ function normInvApprox(p: number): number {
138
+ if (p <= 0) return -6;
139
+ if (p >= 1) return 6;
140
+ const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
141
+ 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
142
+ const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
143
+ 6.680131188771972e+01, -1.328068155288572e+01];
144
+ const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
145
+ -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
146
+ const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00];
147
+ const pLow = 0.02425, pHigh = 1 - pLow;
148
+
149
+ let q: number, r: number;
150
+ if (p < pLow) {
151
+ q = Math.sqrt(-2 * Math.log(p));
152
+ return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
153
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
154
+ } else if (p <= pHigh) {
155
+ q = p - 0.5; r = q * q;
156
+ return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q /
157
+ (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1);
158
+ } else {
159
+ q = Math.sqrt(-2 * Math.log(1 - p));
160
+ return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
161
+ ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * EMSR-b (Expected Marginal Seat Revenue-b) multi-class yield management.
167
+ * Rate classes must be provided in any order; sorted high→low ADR internally.
168
+ * capacity: total rooms available.
169
+ */
170
+ export function emsrYieldManagement(
171
+ capacity: number,
172
+ rateClasses: RateClass[],
173
+ ): EMSRResult {
174
+ if (capacity <= 0) throw new Error('capacity must be positive');
175
+ if (rateClasses.length === 0) throw new Error('At least one rate class required');
176
+
177
+ // Sort descending by ADR
178
+ const sorted = [...rateClasses].sort((a, b) => b.adr - a.adr);
179
+
180
+ const protectionLevels: number[] = [];
181
+ const bookingLimits: number[] = [];
182
+
183
+ // EMSR-b: aggregate demand from classes j+1..n and find protection for class j
184
+ for (let j = 0; j < sorted.length - 1; j++) {
185
+ const r_j = sorted[j].adr; // higher class fare
186
+ const r_j1 = sorted[j + 1].adr; // lower class fare
187
+
188
+ // Aggregate demand for classes 0..j (high-value classes)
189
+ let aggMu = 0, aggVar = 0;
190
+ for (let k = 0; k <= j; k++) {
191
+ aggMu += sorted[k].expectedDemand;
192
+ aggVar += sorted[k].demandStdDev ** 2;
193
+ }
194
+ const aggSigma = Math.sqrt(aggVar);
195
+
196
+ const prot = littlewoodProtection(r_j1, r_j, aggMu, aggSigma);
197
+ protectionLevels.push(prot);
198
+ }
199
+ protectionLevels.push(0); // lowest class has no protection
200
+
201
+ // Booking limits: cumulative from lowest to highest
202
+ let remaining = capacity;
203
+ const bls: number[] = new Array(sorted.length).fill(0);
204
+ for (let j = sorted.length - 1; j >= 0; j--) {
205
+ const protect = j > 0 ? protectionLevels[j - 1] : 0;
206
+ bls[j] = Math.max(0, remaining - protect);
207
+ remaining = Math.max(0, remaining - sorted[j].expectedDemand);
208
+ }
209
+
210
+ // Expected revenue estimate
211
+ let expectedRevenue = 0;
212
+ for (let j = 0; j < sorted.length; j++) {
213
+ const expectedSales = Math.min(sorted[j].expectedDemand, bls[j]);
214
+ expectedRevenue += expectedSales * sorted[j].adr;
215
+ }
216
+
217
+ return { protectionLevels, bookingLimits: bls, expectedRevenue, rateClasses: sorted };
218
+ }
219
+
220
+ // ─── RevPAR / ADR / Occupancy ─────────────────────────────────────────────────
221
+
222
+ export function revparAnalysis(
223
+ availableRooms: number,
224
+ occupiedRooms: number,
225
+ totalRevenue: number,
226
+ ): RevPARResult {
227
+ if (availableRooms <= 0) throw new Error('availableRooms must be positive');
228
+ if (occupiedRooms < 0) throw new Error('occupiedRooms must be non-negative');
229
+ if (occupiedRooms > availableRooms) throw new Error('occupiedRooms cannot exceed availableRooms');
230
+ if (totalRevenue < 0) throw new Error('totalRevenue must be non-negative');
231
+
232
+ const occupancyRate = occupiedRooms / availableRooms;
233
+ const adr = occupiedRooms > 0 ? totalRevenue / occupiedRooms : 0;
234
+ const revpar = adr * occupancyRate;
235
+
236
+ return { availableRooms, occupiedRooms, occupancyRate, adr, revpar, totalRevenue };
237
+ }
238
+
239
+ // ─── Overbooking optimization ─────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Optimal overbooking level using newsvendor-style model.
243
+ * Overbook = Q such that P(no-shows ≥ overbook) = walkCost / (revenue + walkCost)
244
+ *
245
+ * No-show count modeled as Binomial(n+overbook, noShowRate) ≈ Normal.
246
+ */
247
+ export function overbookingOptimization(
248
+ capacity: number,
249
+ noShowRate: number,
250
+ adr: number,
251
+ walkCostPerGuest: number,
252
+ expectedBookings: number,
253
+ ): OverbookingResult {
254
+ if (capacity <= 0) throw new Error('capacity must be positive');
255
+ if (noShowRate < 0 || noShowRate > 1) throw new Error('noShowRate must be in [0, 1]');
256
+ if (adr <= 0) throw new Error('adr must be positive');
257
+ if (walkCostPerGuest < 0) throw new Error('walkCostPerGuest must be non-negative');
258
+
259
+ // Critical ratio for overbooking newsvendor
260
+ const criticalRatio = adr / (adr + walkCostPerGuest);
261
+
262
+ // No-show distribution at bookings = capacity + overbook
263
+ // Expected no-shows = noShowRate × bookings
264
+ // For small overbook, find overbook* where P(no-shows ≥ overbook) = 1 - criticalRatio
265
+ let optOverbook = 0;
266
+ for (let ob = 0; ob <= Math.floor(capacity * 0.3); ob++) {
267
+ const bookings = capacity + ob;
268
+ const mu = noShowRate * bookings;
269
+ const sigma = Math.sqrt(bookings * noShowRate * (1 - noShowRate));
270
+ const pNoShowGe = 1 - normalCDF((ob - mu) / Math.max(sigma, 0.1));
271
+ if (pNoShowGe < 1 - criticalRatio) {
272
+ optOverbook = Math.max(0, ob - 1);
273
+ break;
274
+ }
275
+ optOverbook = ob;
276
+ }
277
+
278
+ const authorizedBookings = capacity + optOverbook;
279
+ const mu = noShowRate * authorizedBookings;
280
+ const sigma = Math.sqrt(authorizedBookings * noShowRate * (1 - noShowRate));
281
+
282
+ // Expected walks = E[max(0, shows - capacity)] where shows = bookings - noShows
283
+ let expectedWalks = 0;
284
+ for (let w = 1; w <= optOverbook + 5; w++) {
285
+ const pWalk = 1 - normalCDF((w - 0.5 - (authorizedBookings - mu - capacity)) / Math.max(sigma, 0.1));
286
+ expectedWalks += pWalk;
287
+ if (pWalk < 1e-6) break;
288
+ }
289
+ expectedWalks = Math.max(0, expectedWalks);
290
+
291
+ const expectedWalkCost = expectedWalks * walkCostPerGuest;
292
+ // Revenue gain vs no-overbooking: each additional booking accepted at ADR, less expected walk cost
293
+ const expectedRevenueGain = Math.max(0, optOverbook * adr * noShowRate - expectedWalkCost);
294
+
295
+ return { capacity, overbook: optOverbook, authorizedBookings, noShowRate, walkCostPerGuest, expectedWalkCost, expectedRevenueGain };
296
+ }
297
+
298
+ // ─── Group displacement ───────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Calculate whether accepting a group booking displaces more-valuable transient demand.
302
+ */
303
+ export function groupDisplacementAnalysis(
304
+ groupAdr: number,
305
+ groupRoomNights: number,
306
+ transientAdr: number,
307
+ transientDemandProbability = 1.0,
308
+ ): GroupDisplacementResult {
309
+ if (groupAdr <= 0) throw new Error('groupAdr must be positive');
310
+ if (groupRoomNights <= 0) throw new Error('groupRoomNights must be positive');
311
+ if (transientAdr <= 0) throw new Error('transientAdr must be positive');
312
+
313
+ const displacementCost = (transientAdr - groupAdr) * groupRoomNights * transientDemandProbability;
314
+ const netValue = groupAdr * groupRoomNights - (transientAdr * groupRoomNights * transientDemandProbability);
315
+ const profitable = netValue > 0;
316
+
317
+ return { groupAdr, groupRoomNights, transientAdr, displacementCost, netValue, profitable };
318
+ }
319
+
320
+ // ─── Demand forecasting with weekly seasonality ───────────────────────────────
321
+
322
+ /**
323
+ * Hotel room demand forecast using exponential smoothing + weekly seasonality indices.
324
+ * Requires at least 14 days of history for seasonal estimation.
325
+ */
326
+ export function hotelDemandForecast(
327
+ historical: number[],
328
+ forecastDays: number,
329
+ ): HotelForecastResult {
330
+ if (historical.length < 14) throw new Error('At least 14 days of history required for seasonal estimation');
331
+ if (forecastDays < 1) throw new Error('forecastDays must be ≥ 1');
332
+
333
+ // Estimate weekly seasonal indices (7 days)
334
+ const seasonalIndices = Array(7).fill(0);
335
+ const dayCounts = Array(7).fill(0);
336
+ const overallMean = historical.reduce((a, b) => a + b, 0) / historical.length;
337
+
338
+ historical.forEach((v, i) => {
339
+ const dow = i % 7;
340
+ seasonalIndices[dow] += v;
341
+ dayCounts[dow]++;
342
+ });
343
+ seasonalIndices.forEach((s, i) => {
344
+ seasonalIndices[i] = overallMean > 0 ? (s / dayCounts[i]) / overallMean : 1;
345
+ });
346
+
347
+ // Deseasonalise
348
+ const deseason = historical.map((v, i) => {
349
+ const idx = seasonalIndices[i % 7];
350
+ return idx > 0 ? v / idx : v;
351
+ });
352
+
353
+ // Simple exponential smoothing on deseasonalised series (alpha=0.3)
354
+ const alpha = 0.3;
355
+ const s = [deseason[0]];
356
+ for (let i = 1; i < deseason.length; i++) s.push(alpha * deseason[i] + (1 - alpha) * s[i - 1]);
357
+ const lastLevel = s[s.length - 1];
358
+
359
+ // RMSE on deseasonalised
360
+ let errSum = 0;
361
+ for (let i = 1; i < historical.length; i++) {
362
+ errSum += (historical[i] - s[i - 1] * seasonalIndices[i % 7]) ** 2;
363
+ }
364
+ const rmse = Math.sqrt(errSum / (historical.length - 1));
365
+
366
+ // Forecast
367
+ const startDow = historical.length % 7;
368
+ const forecast = Array.from({ length: forecastDays }, (_, i) => {
369
+ return Math.max(0, Math.round(lastLevel * seasonalIndices[(startDow + i) % 7]));
370
+ });
371
+
372
+ return { historical, forecast, seasonalIndices, rmse };
373
+ }
374
+
375
+ // ─── Receipt ──────────────────────────────────────────────────────────────────
376
+
377
+ export interface TravelAnalysisResult {
378
+ emsr?: EMSRResult;
379
+ revpar?: RevPARResult;
380
+ overbooking?: OverbookingResult;
381
+ groupDisplacement?: GroupDisplacementResult;
382
+ forecast?: HotelForecastResult;
383
+ converged: true;
384
+ }
385
+
386
+ export function buildTravelReceipt(
387
+ result: TravelAnalysisResult,
388
+ options?: RevenueReceiptOptions,
389
+ ): DomainSimulationReceipt {
390
+ const violations: Array<{ criterion: string; message: string }> = [];
391
+
392
+ if (result.revpar && result.revpar.occupancyRate < 0.40) {
393
+ violations.push({
394
+ criterion: 'occupancy',
395
+ message: `Occupancy ${(result.revpar.occupancyRate * 100).toFixed(1)}% is critically low (<40%)`,
396
+ });
397
+ }
398
+ if (result.groupDisplacement && !result.groupDisplacement.profitable) {
399
+ violations.push({
400
+ criterion: 'group_displacement',
401
+ message: `Group booking net value is negative ($${result.groupDisplacement.netValue.toFixed(0)}) — displaces more valuable transient demand`,
402
+ });
403
+ }
404
+
405
+ return buildDomainSimulationReceipt({
406
+ plugin: 'travel-hospitality',
407
+ pluginVersion: '1.0.0',
408
+ runId: options?.runId ?? `travel-${Date.now().toString(36)}`,
409
+ solverConfig: { solverType: 'revenue-management', scale: 'property' },
410
+ resultSummary: {
411
+ revpar: result.revpar?.revpar ?? null,
412
+ occupancyRate: result.revpar?.occupancyRate ?? null,
413
+ emsrExpectedRevenue: result.emsr?.expectedRevenue ?? null,
414
+ overbookLevel: result.overbooking?.overbook ?? null,
415
+ },
416
+ cael: { version: 'cael.v1', event: 'travel_hospitality.revenue_management', solverType: 'travel-hospitality.emsr-b' },
417
+ acceptance: { accepted: violations.length === 0, violations },
418
+ });
419
+ }
@@ -0,0 +1,25 @@
1
+ /** @itinerary Trait — Trip itinerary planning. @trait itinerary */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export interface ItineraryItem { id: string; day: number; time: string; activity: string; location: string; durationMin: number; bookingRef?: string; notes?: string; }
5
+ export interface ItineraryConfig { tripName: string; startDate: string; endDate: string; travelers: number; items: ItineraryItem[]; }
6
+ export interface ItineraryState { currentDay: number; completedItems: string[]; totalDays: number; }
7
+
8
+ const defaultConfig: ItineraryConfig = { tripName: '', startDate: '', endDate: '', travelers: 1, items: [] };
9
+
10
+ export function createItineraryHandler(): TraitHandler<ItineraryConfig> {
11
+ return { name: 'itinerary', defaultConfig,
12
+ onAttach(n: HSPlusNode, c: ItineraryConfig, ctx: TraitContext) {
13
+ const days = c.startDate && c.endDate ? Math.max(1, Math.ceil((new Date(c.endDate).getTime() - new Date(c.startDate).getTime()) / 86400000)) : 1;
14
+ n.__itinState = { currentDay: 1, completedItems: [], totalDays: days };
15
+ ctx.emit?.('itinerary:planned', { days, items: c.items.length });
16
+ },
17
+ onDetach(n: HSPlusNode, _c: ItineraryConfig, ctx: TraitContext) { delete n.__itinState; ctx.emit?.('itinerary:removed'); },
18
+ onUpdate() {},
19
+ onEvent(n: HSPlusNode, _c: ItineraryConfig, ctx: TraitContext, e: TraitEvent) {
20
+ const s = n.__itinState as ItineraryState | undefined; if (!s) return;
21
+ if (e.type === 'itinerary:complete_item') { const id = e.payload?.itemId as string; if (!s.completedItems.includes(id)) { s.completedItems.push(id); ctx.emit?.('itinerary:item_done', { id, completed: s.completedItems.length }); } }
22
+ if (e.type === 'itinerary:next_day') { s.currentDay = Math.min(s.totalDays, s.currentDay + 1); ctx.emit?.('itinerary:day_changed', { day: s.currentDay }); }
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,24 @@
1
+ /** @rate_management Trait — Dynamic pricing and rate control. @trait rate_management */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export interface RatePeriod { startDate: string; endDate: string; rate: number; minStay: number; }
5
+ export interface RateManagementConfig { baseRate: number; currency: string; seasons: RatePeriod[]; weekendMultiplier: number; dynamicPricingEnabled: boolean; minRate: number; maxRate: number; }
6
+ export interface RateManagementState { currentRate: number; occupancyPercent: number; revenueToday: number; }
7
+
8
+ const defaultConfig: RateManagementConfig = { baseRate: 100, currency: 'USD', seasons: [], weekendMultiplier: 1.2, dynamicPricingEnabled: false, minRate: 50, maxRate: 500 };
9
+
10
+ export function createRateManagementHandler(): TraitHandler<RateManagementConfig> {
11
+ return { name: 'rate_management', defaultConfig,
12
+ onAttach(n: HSPlusNode, c: RateManagementConfig, ctx: TraitContext) { n.__rateState = { currentRate: c.baseRate, occupancyPercent: 0, revenueToday: 0 }; ctx.emit?.('rate:configured', { base: c.baseRate }); },
13
+ onDetach(n: HSPlusNode, _c: RateManagementConfig, ctx: TraitContext) { delete n.__rateState; ctx.emit?.('rate:removed'); },
14
+ onUpdate() {},
15
+ onEvent(n: HSPlusNode, c: RateManagementConfig, ctx: TraitContext, e: TraitEvent) {
16
+ const s = n.__rateState as RateManagementState | undefined; if (!s) return;
17
+ if (e.type === 'rate:update_occupancy') {
18
+ s.occupancyPercent = (e.payload?.occupancy as number) ?? 0;
19
+ if (c.dynamicPricingEnabled) { s.currentRate = Math.min(c.maxRate, Math.max(c.minRate, c.baseRate * (1 + s.occupancyPercent / 100))); ctx.emit?.('rate:adjusted', { rate: s.currentRate, occupancy: s.occupancyPercent }); }
20
+ }
21
+ if (e.type === 'rate:record_sale') { s.revenueToday += s.currentRate; ctx.emit?.('rate:sale_recorded', { revenue: s.revenueToday }); }
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,27 @@
1
+ /** @reservation Trait — Booking and reservation management. @trait reservation */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type ReservationStatus = 'pending' | 'confirmed' | 'checked_in' | 'checked_out' | 'cancelled' | 'no_show';
5
+ export interface ReservationConfig { confirmationNumber: string; guestName: string; roomType: string; checkIn: string; checkOut: string; guests: number; ratePerNight: number; currency: string; specialRequests?: string; }
6
+ export interface ReservationState { status: ReservationStatus; totalNights: number; totalCost: number; }
7
+
8
+ const defaultConfig: ReservationConfig = { confirmationNumber: '', guestName: '', roomType: 'standard', checkIn: '', checkOut: '', guests: 1, ratePerNight: 0, currency: 'USD' };
9
+
10
+ export function createReservationHandler(): TraitHandler<ReservationConfig> {
11
+ return { name: 'reservation', defaultConfig,
12
+ onAttach(n: HSPlusNode, c: ReservationConfig, ctx: TraitContext) {
13
+ const nights = c.checkIn && c.checkOut ? Math.max(1, Math.ceil((new Date(c.checkOut).getTime() - new Date(c.checkIn).getTime()) / 86400000)) : 1;
14
+ n.__resState = { status: 'pending' as ReservationStatus, totalNights: nights, totalCost: nights * c.ratePerNight };
15
+ ctx.emit?.('reservation:created', { confirmation: c.confirmationNumber, nights });
16
+ },
17
+ onDetach(n: HSPlusNode, _c: ReservationConfig, ctx: TraitContext) { delete n.__resState; ctx.emit?.('reservation:removed'); },
18
+ onUpdate() {},
19
+ onEvent(n: HSPlusNode, _c: ReservationConfig, ctx: TraitContext, e: TraitEvent) {
20
+ const s = n.__resState as ReservationState | undefined; if (!s) return;
21
+ if (e.type === 'reservation:confirm') { s.status = 'confirmed'; ctx.emit?.('reservation:confirmed'); }
22
+ if (e.type === 'reservation:check_in') { s.status = 'checked_in'; ctx.emit?.('reservation:checked_in'); }
23
+ if (e.type === 'reservation:check_out') { s.status = 'checked_out'; ctx.emit?.('reservation:checked_out', { total: s.totalCost }); }
24
+ if (e.type === 'reservation:cancel') { s.status = 'cancelled'; ctx.emit?.('reservation:cancelled'); }
25
+ },
26
+ };
27
+ }
@@ -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; [key: string]: unknown; }
3
+ export interface TraitEvent { type: string; payload?: Record<string, unknown>; [key: string]: unknown; }
4
+ export interface TraitHandler<T = unknown> { name: string; defaultConfig: T; onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void; onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void; onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void; onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void; }
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true }, "include": ["src"] }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts'],
8
+ passWithNoTests: true,
9
+ },
10
+ });