@higher.archi/boe 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/engines/decay/compiler.d.ts +11 -0
  2. package/dist/engines/decay/compiler.d.ts.map +1 -0
  3. package/dist/engines/decay/compiler.js +216 -0
  4. package/dist/engines/decay/compiler.js.map +1 -0
  5. package/dist/engines/decay/engine.d.ts +48 -0
  6. package/dist/engines/decay/engine.d.ts.map +1 -0
  7. package/dist/engines/decay/engine.js +90 -0
  8. package/dist/engines/decay/engine.js.map +1 -0
  9. package/dist/engines/decay/index.d.ts +9 -0
  10. package/dist/engines/decay/index.d.ts.map +1 -0
  11. package/dist/engines/decay/index.js +21 -0
  12. package/dist/engines/decay/index.js.map +1 -0
  13. package/dist/engines/decay/strategy.d.ts +19 -0
  14. package/dist/engines/decay/strategy.d.ts.map +1 -0
  15. package/dist/engines/decay/strategy.js +284 -0
  16. package/dist/engines/decay/strategy.js.map +1 -0
  17. package/dist/engines/decay/types.d.ts +148 -0
  18. package/dist/engines/decay/types.d.ts.map +1 -0
  19. package/dist/engines/decay/types.js +39 -0
  20. package/dist/engines/decay/types.js.map +1 -0
  21. package/dist/engines/negotiation/compiler.d.ts +11 -0
  22. package/dist/engines/negotiation/compiler.d.ts.map +1 -0
  23. package/dist/engines/negotiation/compiler.js +177 -0
  24. package/dist/engines/negotiation/compiler.js.map +1 -0
  25. package/dist/engines/negotiation/engine.d.ts +46 -0
  26. package/dist/engines/negotiation/engine.d.ts.map +1 -0
  27. package/dist/engines/negotiation/engine.js +88 -0
  28. package/dist/engines/negotiation/engine.js.map +1 -0
  29. package/dist/engines/negotiation/index.d.ts +8 -0
  30. package/dist/engines/negotiation/index.d.ts.map +1 -0
  31. package/dist/engines/negotiation/index.js +17 -0
  32. package/dist/engines/negotiation/index.js.map +1 -0
  33. package/dist/engines/negotiation/strategy.d.ts +18 -0
  34. package/dist/engines/negotiation/strategy.d.ts.map +1 -0
  35. package/dist/engines/negotiation/strategy.js +439 -0
  36. package/dist/engines/negotiation/strategy.js.map +1 -0
  37. package/dist/engines/negotiation/types.d.ts +179 -0
  38. package/dist/engines/negotiation/types.d.ts.map +1 -0
  39. package/dist/engines/negotiation/types.js +10 -0
  40. package/dist/engines/negotiation/types.js.map +1 -0
  41. package/dist/engines/sentiment/engine.d.ts +25 -1
  42. package/dist/engines/sentiment/engine.d.ts.map +1 -1
  43. package/dist/engines/sentiment/engine.js +119 -0
  44. package/dist/engines/sentiment/engine.js.map +1 -1
  45. package/dist/engines/sentiment/index.d.ts +1 -1
  46. package/dist/engines/sentiment/index.d.ts.map +1 -1
  47. package/dist/engines/sentiment/index.js.map +1 -1
  48. package/dist/engines/sentiment/strategy.d.ts +2 -0
  49. package/dist/engines/sentiment/strategy.d.ts.map +1 -1
  50. package/dist/engines/sentiment/strategy.js +7 -8
  51. package/dist/engines/sentiment/strategy.js.map +1 -1
  52. package/dist/engines/sentiment/types.d.ts +9 -0
  53. package/dist/engines/sentiment/types.d.ts.map +1 -1
  54. package/dist/engines/sentiment/types.js.map +1 -1
  55. package/dist/index.d.ts +7 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +22 -6
  58. package/dist/index.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/engines/decay/compiler.ts +276 -0
  61. package/src/engines/decay/engine.ts +119 -0
  62. package/src/engines/decay/index.ts +42 -0
  63. package/src/engines/decay/strategy.ts +400 -0
  64. package/src/engines/decay/types.ts +221 -0
  65. package/src/engines/negotiation/compiler.ts +229 -0
  66. package/src/engines/negotiation/engine.ts +117 -0
  67. package/src/engines/negotiation/index.ts +42 -0
  68. package/src/engines/negotiation/strategy.ts +587 -0
  69. package/src/engines/negotiation/types.ts +244 -0
  70. package/src/engines/sentiment/engine.ts +157 -1
  71. package/src/engines/sentiment/index.ts +2 -1
  72. package/src/engines/sentiment/strategy.ts +12 -9
  73. package/src/engines/sentiment/types.ts +10 -0
  74. package/src/index.ts +70 -1
@@ -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();