@higher.archi/boe 1.0.27 → 1.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engines/decay/compiler.d.ts +11 -0
- package/dist/engines/decay/compiler.d.ts.map +1 -0
- package/dist/engines/decay/compiler.js +216 -0
- package/dist/engines/decay/compiler.js.map +1 -0
- package/dist/engines/decay/engine.d.ts +79 -0
- package/dist/engines/decay/engine.d.ts.map +1 -0
- package/dist/engines/decay/engine.js +159 -0
- package/dist/engines/decay/engine.js.map +1 -0
- package/dist/engines/decay/index.d.ts +9 -0
- package/dist/engines/decay/index.d.ts.map +1 -0
- package/dist/engines/decay/index.js +21 -0
- package/dist/engines/decay/index.js.map +1 -0
- package/dist/engines/decay/strategy.d.ts +21 -0
- package/dist/engines/decay/strategy.d.ts.map +1 -0
- package/dist/engines/decay/strategy.js +308 -0
- package/dist/engines/decay/strategy.js.map +1 -0
- package/dist/engines/decay/types.d.ts +157 -0
- package/dist/engines/decay/types.d.ts.map +1 -0
- package/dist/engines/decay/types.js +39 -0
- package/dist/engines/decay/types.js.map +1 -0
- package/dist/engines/negotiation/compiler.d.ts +11 -0
- package/dist/engines/negotiation/compiler.d.ts.map +1 -0
- package/dist/engines/negotiation/compiler.js +177 -0
- package/dist/engines/negotiation/compiler.js.map +1 -0
- package/dist/engines/negotiation/engine.d.ts +46 -0
- package/dist/engines/negotiation/engine.d.ts.map +1 -0
- package/dist/engines/negotiation/engine.js +88 -0
- package/dist/engines/negotiation/engine.js.map +1 -0
- package/dist/engines/negotiation/index.d.ts +8 -0
- package/dist/engines/negotiation/index.d.ts.map +1 -0
- package/dist/engines/negotiation/index.js +17 -0
- package/dist/engines/negotiation/index.js.map +1 -0
- package/dist/engines/negotiation/strategy.d.ts +18 -0
- package/dist/engines/negotiation/strategy.d.ts.map +1 -0
- package/dist/engines/negotiation/strategy.js +439 -0
- package/dist/engines/negotiation/strategy.js.map +1 -0
- package/dist/engines/negotiation/types.d.ts +179 -0
- package/dist/engines/negotiation/types.d.ts.map +1 -0
- package/dist/engines/negotiation/types.js +10 -0
- package/dist/engines/negotiation/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engines/decay/compiler.ts +276 -0
- package/src/engines/decay/engine.ts +211 -0
- package/src/engines/decay/index.ts +43 -0
- package/src/engines/decay/strategy.ts +433 -0
- package/src/engines/decay/types.ts +231 -0
- package/src/engines/negotiation/compiler.ts +229 -0
- package/src/engines/negotiation/engine.ts +117 -0
- package/src/engines/negotiation/index.ts +42 -0
- package/src/engines/negotiation/strategy.ts +587 -0
- package/src/engines/negotiation/types.ts +244 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiation Engine Strategy
|
|
3
|
+
*
|
|
4
|
+
* Core execution logic for all negotiation strategies:
|
|
5
|
+
* - zopa-analysis: Compute zones of possible agreement per dimension
|
|
6
|
+
* - concession-planning: Simulate round-by-round concession trajectories
|
|
7
|
+
* - package-optimization: Find optimal deal packages within the ZOPA
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CompiledNegotiationRuleSet,
|
|
12
|
+
CompiledZopaAnalysisRuleSet,
|
|
13
|
+
CompiledConcessionPlanningRuleSet,
|
|
14
|
+
CompiledPackageOptimizationRuleSet,
|
|
15
|
+
CompiledValueFunction,
|
|
16
|
+
CompiledPartyProfile,
|
|
17
|
+
NegotiationDimension,
|
|
18
|
+
NegotiationOptions,
|
|
19
|
+
NegotiationResult,
|
|
20
|
+
ZopaRange,
|
|
21
|
+
ConcessionStep,
|
|
22
|
+
PackageCandidate,
|
|
23
|
+
DealOutcome,
|
|
24
|
+
PartyPosition
|
|
25
|
+
} from './types';
|
|
26
|
+
|
|
27
|
+
// ========================================
|
|
28
|
+
// Executor
|
|
29
|
+
// ========================================
|
|
30
|
+
|
|
31
|
+
export class NegotiationExecutor {
|
|
32
|
+
run(
|
|
33
|
+
ruleSet: CompiledNegotiationRuleSet,
|
|
34
|
+
options: NegotiationOptions = {}
|
|
35
|
+
): NegotiationResult {
|
|
36
|
+
switch (ruleSet.strategy) {
|
|
37
|
+
case 'zopa-analysis':
|
|
38
|
+
return this.runZopaAnalysis(ruleSet);
|
|
39
|
+
case 'concession-planning':
|
|
40
|
+
return this.runConcessionPlanning(ruleSet, options);
|
|
41
|
+
case 'package-optimization':
|
|
42
|
+
return this.runPackageOptimization(ruleSet, options);
|
|
43
|
+
default:
|
|
44
|
+
throw new Error(`Unknown negotiation strategy: '${(ruleSet as any).strategy}'`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ========================================
|
|
49
|
+
// ZOPA Analysis
|
|
50
|
+
// ========================================
|
|
51
|
+
|
|
52
|
+
private runZopaAnalysis(
|
|
53
|
+
ruleSet: CompiledZopaAnalysisRuleSet
|
|
54
|
+
): NegotiationResult {
|
|
55
|
+
const startTime = performance.now();
|
|
56
|
+
|
|
57
|
+
const [partyA, partyB] = ruleSet.parties;
|
|
58
|
+
const ranges: ZopaRange[] = [];
|
|
59
|
+
let hasAgreement = true;
|
|
60
|
+
|
|
61
|
+
for (const dim of ruleSet.dimensions) {
|
|
62
|
+
const posA = findPosition(partyA, dim.id);
|
|
63
|
+
const posB = findPosition(partyB, dim.id);
|
|
64
|
+
|
|
65
|
+
if (!posA || !posB) {
|
|
66
|
+
hasAgreement = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const range = computeZopaRange(dim, posA, posB);
|
|
71
|
+
|
|
72
|
+
if (range === null) {
|
|
73
|
+
hasAgreement = false;
|
|
74
|
+
} else {
|
|
75
|
+
ranges.push(range);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// zopaVolume = product of normalized range sizes
|
|
80
|
+
let zopaVolume = 0;
|
|
81
|
+
if (hasAgreement && ranges.length > 0) {
|
|
82
|
+
zopaVolume = 1;
|
|
83
|
+
for (const range of ranges) {
|
|
84
|
+
const dim = ruleSet.dimensions.find(d => d.id === range.dimensionId)!;
|
|
85
|
+
const posA = findPosition(partyA, dim.id)!;
|
|
86
|
+
const posB = findPosition(partyB, dim.id)!;
|
|
87
|
+
const fullSpan = Math.abs(
|
|
88
|
+
Math.max(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway) -
|
|
89
|
+
Math.min(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway)
|
|
90
|
+
);
|
|
91
|
+
const normalizedSize = fullSpan > 0 ? range.size / fullSpan : 0;
|
|
92
|
+
zopaVolume *= normalizedSize;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const outcome: DealOutcome = hasAgreement ? 'agreed' : 'no-zopa';
|
|
97
|
+
|
|
98
|
+
// BATNA comparison -- use midpoint of ZOPA as deal value
|
|
99
|
+
const batnaComparison = buildBatnaComparison(
|
|
100
|
+
ruleSet.parties,
|
|
101
|
+
ruleSet.valueWeights,
|
|
102
|
+
ruleSet.dimensions,
|
|
103
|
+
hasAgreement ? ranges : [],
|
|
104
|
+
hasAgreement
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const executionTimeMs = round(performance.now() - startTime, 2);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
outcome,
|
|
111
|
+
zopa: {
|
|
112
|
+
ranges,
|
|
113
|
+
hasAgreement,
|
|
114
|
+
zopaVolume: round(zopaVolume, 6)
|
|
115
|
+
},
|
|
116
|
+
batnaComparison,
|
|
117
|
+
strategy: 'zopa-analysis',
|
|
118
|
+
executionTimeMs
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ========================================
|
|
123
|
+
// Concession Planning
|
|
124
|
+
// ========================================
|
|
125
|
+
|
|
126
|
+
private runConcessionPlanning(
|
|
127
|
+
ruleSet: CompiledConcessionPlanningRuleSet,
|
|
128
|
+
options: NegotiationOptions
|
|
129
|
+
): NegotiationResult {
|
|
130
|
+
const startTime = performance.now();
|
|
131
|
+
|
|
132
|
+
const { style, rounds, startingPosition } = ruleSet.config;
|
|
133
|
+
const [partyA, partyB] = ruleSet.parties;
|
|
134
|
+
const steps: ConcessionStep[] = [];
|
|
135
|
+
let convergenceRound: number | undefined;
|
|
136
|
+
let finalDeal: Record<string, number> | undefined;
|
|
137
|
+
|
|
138
|
+
// Initialize current offers per party per dimension
|
|
139
|
+
const currentOffers: Record<string, Record<string, number>> = {};
|
|
140
|
+
for (const party of ruleSet.parties) {
|
|
141
|
+
currentOffers[party.id] = {};
|
|
142
|
+
for (const pos of party.positions) {
|
|
143
|
+
if (startingPosition === 'midpoint') {
|
|
144
|
+
currentOffers[party.id][pos.dimensionId] = (pos.ideal + pos.acceptable) / 2;
|
|
145
|
+
} else {
|
|
146
|
+
currentOffers[party.id][pos.dimensionId] = pos.ideal;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let r = 1; r <= rounds; r++) {
|
|
152
|
+
for (const party of ruleSet.parties) {
|
|
153
|
+
const otherParty = party === partyA ? partyB : partyA;
|
|
154
|
+
const weights = ruleSet.valueWeights[party.id] || [];
|
|
155
|
+
|
|
156
|
+
for (const pos of party.positions) {
|
|
157
|
+
const dimId = pos.dimensionId;
|
|
158
|
+
const current = currentOffers[party.id][dimId];
|
|
159
|
+
const otherCurrent = currentOffers[otherParty.id][dimId];
|
|
160
|
+
const gap = otherCurrent - current;
|
|
161
|
+
|
|
162
|
+
// Compute inverse weight for this dimension -- concede more on low-priority
|
|
163
|
+
const dimWeight = weights.find(w => w.dimensionId === dimId);
|
|
164
|
+
const inverseWeight = dimWeight ? (1 - dimWeight.weight) : 0.5;
|
|
165
|
+
|
|
166
|
+
let concession = 0;
|
|
167
|
+
|
|
168
|
+
switch (style) {
|
|
169
|
+
case 'aggressive':
|
|
170
|
+
concession = gap * 0.5 * Math.pow(0.7, r - 1) * inverseWeight;
|
|
171
|
+
break;
|
|
172
|
+
case 'moderate': {
|
|
173
|
+
const totalTravel = pos.ideal - pos.acceptable;
|
|
174
|
+
const equalStep = rounds > 0 ? totalTravel / rounds : 0;
|
|
175
|
+
// Direction-aware concession
|
|
176
|
+
concession = Math.sign(gap) * Math.abs(equalStep) * inverseWeight;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'collaborative': {
|
|
180
|
+
const fraction = Math.pow(r / rounds, 2);
|
|
181
|
+
concession = gap * fraction * inverseWeight;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
currentOffers[party.id][dimId] = current + concession;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Compute total value for this party's current offer
|
|
190
|
+
const totalValue = computePartyValue(
|
|
191
|
+
party, currentOffers[party.id], ruleSet.dimensions, weights
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const step: ConcessionStep = {
|
|
195
|
+
round: r,
|
|
196
|
+
partyId: party.id,
|
|
197
|
+
offers: { ...currentOffers[party.id] },
|
|
198
|
+
totalValue: round(totalValue, 4)
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
steps.push(step);
|
|
202
|
+
|
|
203
|
+
if (options.onStep) {
|
|
204
|
+
options.onStep(step);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check convergence -- all dimensions within 1% tolerance
|
|
209
|
+
const converged = ruleSet.dimensions.every(dim => {
|
|
210
|
+
const offerA = currentOffers[partyA.id][dim.id];
|
|
211
|
+
const offerB = currentOffers[partyB.id][dim.id];
|
|
212
|
+
const posA = findPosition(partyA, dim.id);
|
|
213
|
+
const posB = findPosition(partyB, dim.id);
|
|
214
|
+
if (!posA || !posB) return false;
|
|
215
|
+
|
|
216
|
+
const fullRange = Math.abs(
|
|
217
|
+
Math.max(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway) -
|
|
218
|
+
Math.min(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway)
|
|
219
|
+
);
|
|
220
|
+
const tolerance = fullRange * 0.01;
|
|
221
|
+
return Math.abs(offerA - offerB) <= tolerance;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (converged && convergenceRound === undefined) {
|
|
225
|
+
convergenceRound = r;
|
|
226
|
+
finalDeal = {};
|
|
227
|
+
for (const dim of ruleSet.dimensions) {
|
|
228
|
+
finalDeal[dim.id] = round(
|
|
229
|
+
(currentOffers[partyA.id][dim.id] + currentOffers[partyB.id][dim.id]) / 2,
|
|
230
|
+
4
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hasAgreement = convergenceRound !== undefined;
|
|
238
|
+
const outcome: DealOutcome = hasAgreement ? 'agreed' : 'no-zopa';
|
|
239
|
+
|
|
240
|
+
// Compute ZOPA for BATNA comparison
|
|
241
|
+
const zopaRanges: ZopaRange[] = [];
|
|
242
|
+
for (const dim of ruleSet.dimensions) {
|
|
243
|
+
const posA = findPosition(partyA, dim.id);
|
|
244
|
+
const posB = findPosition(partyB, dim.id);
|
|
245
|
+
if (posA && posB) {
|
|
246
|
+
const range = computeZopaRange(dim, posA, posB);
|
|
247
|
+
if (range) zopaRanges.push(range);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const batnaComparison = buildBatnaComparison(
|
|
252
|
+
ruleSet.parties,
|
|
253
|
+
ruleSet.valueWeights,
|
|
254
|
+
ruleSet.dimensions,
|
|
255
|
+
hasAgreement && finalDeal
|
|
256
|
+
? ruleSet.dimensions.map(d => ({
|
|
257
|
+
dimensionId: d.id,
|
|
258
|
+
lower: finalDeal![d.id],
|
|
259
|
+
upper: finalDeal![d.id],
|
|
260
|
+
size: 0
|
|
261
|
+
}))
|
|
262
|
+
: zopaRanges,
|
|
263
|
+
hasAgreement
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const executionTimeMs = round(performance.now() - startTime, 2);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
outcome,
|
|
270
|
+
concessionPlan: {
|
|
271
|
+
steps,
|
|
272
|
+
convergenceRound,
|
|
273
|
+
finalDeal
|
|
274
|
+
},
|
|
275
|
+
batnaComparison,
|
|
276
|
+
strategy: 'concession-planning',
|
|
277
|
+
executionTimeMs
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ========================================
|
|
282
|
+
// Package Optimization
|
|
283
|
+
// ========================================
|
|
284
|
+
|
|
285
|
+
private runPackageOptimization(
|
|
286
|
+
ruleSet: CompiledPackageOptimizationRuleSet,
|
|
287
|
+
options: NegotiationOptions
|
|
288
|
+
): NegotiationResult {
|
|
289
|
+
const startTime = performance.now();
|
|
290
|
+
|
|
291
|
+
const [partyA, partyB] = ruleSet.parties;
|
|
292
|
+
const { sampleSize, objectiveWeights } = ruleSet.config;
|
|
293
|
+
|
|
294
|
+
// 1. Compute ZOPA ranges
|
|
295
|
+
const zopaRanges: ZopaRange[] = [];
|
|
296
|
+
let hasAgreement = true;
|
|
297
|
+
|
|
298
|
+
for (const dim of ruleSet.dimensions) {
|
|
299
|
+
const posA = findPosition(partyA, dim.id);
|
|
300
|
+
const posB = findPosition(partyB, dim.id);
|
|
301
|
+
|
|
302
|
+
if (!posA || !posB) {
|
|
303
|
+
hasAgreement = false;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const range = computeZopaRange(dim, posA, posB);
|
|
308
|
+
if (range === null) {
|
|
309
|
+
hasAgreement = false;
|
|
310
|
+
} else {
|
|
311
|
+
zopaRanges.push(range);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 2. If no ZOPA, return no-zopa outcome
|
|
316
|
+
if (!hasAgreement || zopaRanges.length === 0) {
|
|
317
|
+
const batnaComparison = buildBatnaComparison(
|
|
318
|
+
ruleSet.parties, ruleSet.valueWeights, ruleSet.dimensions, [], false
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const executionTimeMs = round(performance.now() - startTime, 2);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
outcome: 'no-zopa',
|
|
325
|
+
zopa: {
|
|
326
|
+
ranges: zopaRanges,
|
|
327
|
+
hasAgreement: false,
|
|
328
|
+
zopaVolume: 0
|
|
329
|
+
},
|
|
330
|
+
batnaComparison,
|
|
331
|
+
strategy: 'package-optimization',
|
|
332
|
+
executionTimeMs
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 3. Sample random points within ZOPA ranges
|
|
337
|
+
const weightsA = ruleSet.valueWeights[partyA.id] || [];
|
|
338
|
+
const weightsB = ruleSet.valueWeights[partyB.id] || [];
|
|
339
|
+
const candidates: PackageCandidate[] = [];
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
342
|
+
const dims: Record<string, number> = {};
|
|
343
|
+
|
|
344
|
+
for (const range of zopaRanges) {
|
|
345
|
+
dims[range.dimensionId] = range.lower + Math.random() * range.size;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Compute party values (0-1 normalized within their range, weighted)
|
|
349
|
+
const partyAValue = computePartyValue(
|
|
350
|
+
partyA, dims, ruleSet.dimensions, weightsA
|
|
351
|
+
);
|
|
352
|
+
const partyBValue = computePartyValue(
|
|
353
|
+
partyB, dims, ruleSet.dimensions, weightsB
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const closeProbability = round(Math.sqrt(partyAValue * partyBValue), 4);
|
|
357
|
+
const marginScore = round(partyAValue, 4);
|
|
358
|
+
const compositeScore = round(
|
|
359
|
+
objectiveWeights.closeProb * closeProbability +
|
|
360
|
+
objectiveWeights.margin * marginScore,
|
|
361
|
+
4
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const candidate: PackageCandidate = {
|
|
365
|
+
dimensions: roundDimensions(dims),
|
|
366
|
+
partyAValue: round(partyAValue, 4),
|
|
367
|
+
partyBValue: round(partyBValue, 4),
|
|
368
|
+
closeProbability,
|
|
369
|
+
marginScore,
|
|
370
|
+
compositeScore
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
candidates.push(candidate);
|
|
374
|
+
|
|
375
|
+
if (options.onPackage) {
|
|
376
|
+
options.onPackage(candidate);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 4. Sort by compositeScore descending, take top 10
|
|
381
|
+
candidates.sort((a, b) => b.compositeScore - a.compositeScore);
|
|
382
|
+
const topPackages = candidates.slice(0, 10);
|
|
383
|
+
const recommended = topPackages[0];
|
|
384
|
+
|
|
385
|
+
// 5. Compare best against BATNA
|
|
386
|
+
const batnaComparison = buildBatnaComparison(
|
|
387
|
+
ruleSet.parties,
|
|
388
|
+
ruleSet.valueWeights,
|
|
389
|
+
ruleSet.dimensions,
|
|
390
|
+
// Use recommended package dimensions as the deal point
|
|
391
|
+
ruleSet.dimensions.map(d => ({
|
|
392
|
+
dimensionId: d.id,
|
|
393
|
+
lower: recommended.dimensions[d.id] ?? 0,
|
|
394
|
+
upper: recommended.dimensions[d.id] ?? 0,
|
|
395
|
+
size: 0
|
|
396
|
+
})),
|
|
397
|
+
true
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Check if BATNA is preferred for either party
|
|
401
|
+
let outcome: DealOutcome = 'agreed';
|
|
402
|
+
for (const partyId of Object.keys(batnaComparison)) {
|
|
403
|
+
if (batnaComparison[partyId].surplus < 0) {
|
|
404
|
+
outcome = 'batna-preferred';
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Compute zopaVolume
|
|
410
|
+
let zopaVolume = 1;
|
|
411
|
+
for (const range of zopaRanges) {
|
|
412
|
+
const dim = ruleSet.dimensions.find(d => d.id === range.dimensionId)!;
|
|
413
|
+
const posA = findPosition(partyA, dim.id)!;
|
|
414
|
+
const posB = findPosition(partyB, dim.id)!;
|
|
415
|
+
const fullSpan = Math.abs(
|
|
416
|
+
Math.max(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway) -
|
|
417
|
+
Math.min(posA.ideal, posA.walkaway, posB.ideal, posB.walkaway)
|
|
418
|
+
);
|
|
419
|
+
const normalizedSize = fullSpan > 0 ? range.size / fullSpan : 0;
|
|
420
|
+
zopaVolume *= normalizedSize;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const executionTimeMs = round(performance.now() - startTime, 2);
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
outcome,
|
|
427
|
+
zopa: {
|
|
428
|
+
ranges: zopaRanges,
|
|
429
|
+
hasAgreement: true,
|
|
430
|
+
zopaVolume: round(zopaVolume, 6)
|
|
431
|
+
},
|
|
432
|
+
recommendedPackage: recommended,
|
|
433
|
+
allPackages: topPackages,
|
|
434
|
+
batnaComparison,
|
|
435
|
+
strategy: 'package-optimization',
|
|
436
|
+
executionTimeMs
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ========================================
|
|
442
|
+
// Module-Level Helpers
|
|
443
|
+
// ========================================
|
|
444
|
+
|
|
445
|
+
function findPosition(party: CompiledPartyProfile, dimensionId: string): PartyPosition | undefined {
|
|
446
|
+
return party.positions.find(p => p.dimensionId === dimensionId);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Compute the ZOPA range for a single dimension.
|
|
451
|
+
*
|
|
452
|
+
* For 'higher-is-better': the party who wants higher values has range
|
|
453
|
+
* [walkaway, ideal], and the party who wants lower values has range
|
|
454
|
+
* [ideal, walkaway]. The ZOPA is the intersection.
|
|
455
|
+
*
|
|
456
|
+
* For 'lower-is-better': reversed logic applies.
|
|
457
|
+
*
|
|
458
|
+
* Returns null if there is no overlap.
|
|
459
|
+
*/
|
|
460
|
+
function computeZopaRange(
|
|
461
|
+
dim: NegotiationDimension,
|
|
462
|
+
posA: PartyPosition,
|
|
463
|
+
posB: PartyPosition
|
|
464
|
+
): ZopaRange | null {
|
|
465
|
+
let rangeALow: number, rangeAHigh: number;
|
|
466
|
+
let rangeBLow: number, rangeBHigh: number;
|
|
467
|
+
|
|
468
|
+
if (dim.direction === 'higher-is-better') {
|
|
469
|
+
// Party A: [walkaway, ideal], Party B: [walkaway, ideal]
|
|
470
|
+
rangeALow = Math.min(posA.walkaway, posA.ideal);
|
|
471
|
+
rangeAHigh = Math.max(posA.walkaway, posA.ideal);
|
|
472
|
+
rangeBLow = Math.min(posB.walkaway, posB.ideal);
|
|
473
|
+
rangeBHigh = Math.max(posB.walkaway, posB.ideal);
|
|
474
|
+
} else {
|
|
475
|
+
// lower-is-better
|
|
476
|
+
rangeALow = Math.min(posA.ideal, posA.walkaway);
|
|
477
|
+
rangeAHigh = Math.max(posA.ideal, posA.walkaway);
|
|
478
|
+
rangeBLow = Math.min(posB.ideal, posB.walkaway);
|
|
479
|
+
rangeBHigh = Math.max(posB.ideal, posB.walkaway);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Intersection
|
|
483
|
+
const lower = Math.max(rangeALow, rangeBLow);
|
|
484
|
+
const upper = Math.min(rangeAHigh, rangeBHigh);
|
|
485
|
+
const size = upper - lower;
|
|
486
|
+
|
|
487
|
+
if (size < 0) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
dimensionId: dim.id,
|
|
493
|
+
lower: round(lower, 4),
|
|
494
|
+
upper: round(upper, 4),
|
|
495
|
+
size: round(size, 4)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Compute a party's normalized value (0-1) for a given set of dimension values.
|
|
501
|
+
* Value is computed as the weighted sum of per-dimension normalized scores.
|
|
502
|
+
*/
|
|
503
|
+
function computePartyValue(
|
|
504
|
+
party: CompiledPartyProfile,
|
|
505
|
+
dimensionValues: Record<string, number>,
|
|
506
|
+
dimensions: NegotiationDimension[],
|
|
507
|
+
weights: CompiledValueFunction[]
|
|
508
|
+
): number {
|
|
509
|
+
let totalValue = 0;
|
|
510
|
+
|
|
511
|
+
for (const dim of dimensions) {
|
|
512
|
+
const pos = findPosition(party, dim.id);
|
|
513
|
+
if (!pos) continue;
|
|
514
|
+
|
|
515
|
+
const value = dimensionValues[dim.id];
|
|
516
|
+
if (value === undefined) continue;
|
|
517
|
+
|
|
518
|
+
const dimWeight = weights.find(w => w.dimensionId === dim.id);
|
|
519
|
+
const weight = dimWeight ? dimWeight.weight : 0;
|
|
520
|
+
|
|
521
|
+
// Normalize within party's range [walkaway, ideal] to [0, 1]
|
|
522
|
+
const range = pos.ideal - pos.walkaway;
|
|
523
|
+
let normalized: number;
|
|
524
|
+
if (range === 0) {
|
|
525
|
+
normalized = value === pos.ideal ? 1 : 0;
|
|
526
|
+
} else {
|
|
527
|
+
normalized = (value - pos.walkaway) / range;
|
|
528
|
+
normalized = Math.max(0, Math.min(1, normalized));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
totalValue += normalized * weight;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return totalValue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Build BATNA comparison for both parties.
|
|
539
|
+
* Uses the midpoint of ZOPA ranges (or deal point) as the deal value.
|
|
540
|
+
*/
|
|
541
|
+
function buildBatnaComparison(
|
|
542
|
+
parties: [CompiledPartyProfile, CompiledPartyProfile],
|
|
543
|
+
valueWeights: Record<string, CompiledValueFunction[]>,
|
|
544
|
+
dimensions: NegotiationDimension[],
|
|
545
|
+
ranges: ZopaRange[],
|
|
546
|
+
hasAgreement: boolean
|
|
547
|
+
): Record<string, { dealValue: number; batnaValue: number; surplus: number }> {
|
|
548
|
+
const result: Record<string, { dealValue: number; batnaValue: number; surplus: number }> = {};
|
|
549
|
+
|
|
550
|
+
// Build deal point from ranges (midpoint)
|
|
551
|
+
const dealPoint: Record<string, number> = {};
|
|
552
|
+
for (const range of ranges) {
|
|
553
|
+
dealPoint[range.dimensionId] = (range.lower + range.upper) / 2;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (const party of parties) {
|
|
557
|
+
const weights = valueWeights[party.id] || [];
|
|
558
|
+
const dealValue = hasAgreement
|
|
559
|
+
? round(computePartyValue(party, dealPoint, dimensions, weights), 4)
|
|
560
|
+
: 0;
|
|
561
|
+
const batnaValue = party.batna ?? 0;
|
|
562
|
+
|
|
563
|
+
result[party.id] = {
|
|
564
|
+
dealValue,
|
|
565
|
+
batnaValue,
|
|
566
|
+
surplus: round(dealValue - batnaValue, 4)
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function round(value: number, decimals: number): number {
|
|
574
|
+
const factor = Math.pow(10, decimals);
|
|
575
|
+
return Math.round(value * factor) / factor;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function roundDimensions(dims: Record<string, number>): Record<string, number> {
|
|
579
|
+
const result: Record<string, number> = {};
|
|
580
|
+
for (const [key, value] of Object.entries(dims)) {
|
|
581
|
+
result[key] = round(value, 4);
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Singleton instance */
|
|
587
|
+
export const negotiationStrategy = new NegotiationExecutor();
|