@indexnetwork/protocol 14.3.2-rc.477.1 → 16.1.1-rc.478.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/IMPLEMENTATION.md +50 -52
  3. package/STABILITY.md +3 -3
  4. package/dist/chat/chat.agent.js +2 -2
  5. package/dist/chat/chat.graph.d.ts +41 -1
  6. package/dist/chat/chat.graph.js +108 -127
  7. package/dist/discovery/hyde.frame.d.ts +4 -4
  8. package/dist/discovery/hyde.graph.d.ts +71 -12
  9. package/dist/discovery/hyde.graph.js +337 -335
  10. package/dist/discovery/lens.inferrer.d.ts +6 -6
  11. package/dist/enrichment/enrichment.graph.d.ts +127 -9
  12. package/dist/enrichment/enrichment.graph.js +633 -655
  13. package/dist/enrichment/enrichment.state.d.ts +1 -1
  14. package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
  15. package/dist/enrichment/enrichment.tools.context-read.js +353 -0
  16. package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
  17. package/dist/enrichment/enrichment.tools.context-write.js +417 -0
  18. package/dist/enrichment/enrichment.tools.d.ts +11 -1
  19. package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
  20. package/dist/enrichment/enrichment.tools.helpers.js +234 -0
  21. package/dist/enrichment/enrichment.tools.js +16 -975
  22. package/dist/index.d.ts +27 -48
  23. package/dist/index.js +17 -27
  24. package/dist/intents/application/intent.graph.d.ts +71 -67
  25. package/dist/intents/application/intent.graph.execute.d.ts +59 -0
  26. package/dist/intents/application/intent.graph.execute.js +301 -0
  27. package/dist/intents/application/intent.graph.infer.d.ts +44 -0
  28. package/dist/intents/application/intent.graph.infer.js +97 -0
  29. package/dist/intents/application/intent.graph.js +96 -888
  30. package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
  31. package/dist/intents/application/intent.graph.reconcile.js +274 -0
  32. package/dist/intents/application/intent.graph.shared.d.ts +70 -0
  33. package/dist/intents/application/intent.graph.shared.js +153 -0
  34. package/dist/intents/domain/intent.state.d.ts +1 -1
  35. package/dist/maintenance/maintenance.graph.d.ts +66 -3
  36. package/dist/maintenance/maintenance.graph.js +155 -156
  37. package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
  38. package/dist/mcp/mcp.authorization-policy.js +14 -34
  39. package/dist/mcp/mcp.server.d.ts +3 -3
  40. package/dist/mcp/mcp.server.js +12 -16
  41. package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
  42. package/dist/negotiations/application/negotiation.candidates.js +162 -0
  43. package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
  44. package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
  45. package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
  46. package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
  47. package/dist/negotiations/application/negotiation.graph.init.js +227 -0
  48. package/dist/negotiations/application/negotiation.graph.js +69 -1388
  49. package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
  50. package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
  51. package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
  52. package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
  53. package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
  54. package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
  55. package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
  56. package/dist/negotiations/domain/negotiation.state.js +0 -1
  57. package/dist/networks/application/indexer.graph.d.ts +165 -7
  58. package/dist/networks/application/indexer.graph.js +339 -388
  59. package/dist/networks/application/indexer.state.d.ts +1 -1
  60. package/dist/networks/application/membership.graph.d.ts +83 -5
  61. package/dist/networks/application/membership.graph.js +177 -207
  62. package/dist/networks/application/network.graph.d.ts +148 -5
  63. package/dist/networks/application/network.graph.js +249 -278
  64. package/dist/networks/domain/membership.state.d.ts +1 -1
  65. package/dist/networks/domain/network.state.d.ts +1 -1
  66. package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
  67. package/dist/opportunities/application/delivery-card.cache.js +2 -2
  68. package/dist/opportunities/application/index.d.ts +2 -2
  69. package/dist/opportunities/application/index.js +2 -2
  70. package/dist/opportunities/application/opportunity.evaluator.js +1 -1
  71. package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
  72. package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
  73. package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
  74. package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
  75. package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
  76. package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
  77. package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
  78. package/dist/opportunities/application/opportunity.graph.js +120 -3690
  79. package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
  80. package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
  81. package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
  82. package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
  83. package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
  84. package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
  85. package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
  86. package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
  87. package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
  88. package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
  89. package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
  90. package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
  91. package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
  92. package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
  93. package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
  94. package/dist/opportunities/application/opportunity.tools.js +28 -711
  95. package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
  96. package/dist/opportunities/application/opportunity.tools.list.js +493 -0
  97. package/dist/opportunities/domain/index.d.ts +3 -3
  98. package/dist/opportunities/domain/index.js +3 -3
  99. package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
  100. package/dist/opportunities/index.d.ts +6 -6
  101. package/dist/opportunities/index.js +4 -4
  102. package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
  103. package/dist/opportunities/radar/radar.graph.d.ts +57 -13
  104. package/dist/opportunities/radar/radar.graph.js +470 -471
  105. package/dist/premises/premise.graph.d.ts +88 -20
  106. package/dist/premises/premise.graph.js +207 -218
  107. package/dist/questions/domain/question.schema.d.ts +40 -40
  108. package/dist/shared/agent/tool.helpers.d.ts +21 -0
  109. package/dist/shared/agent/tool.registry.d.ts +4 -3
  110. package/dist/shared/agent/tool.registry.js +1 -1
  111. package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
  112. package/dist/shared/interfaces/database.capabilities.js +7 -0
  113. package/dist/shared/interfaces/database.entities.d.ts +533 -0
  114. package/dist/shared/interfaces/database.entities.js +10 -0
  115. package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
  116. package/dist/shared/interfaces/database.identity-queries.js +4 -0
  117. package/dist/shared/interfaces/database.interface.d.ts +15 -2277
  118. package/dist/shared/interfaces/database.interface.js +8 -0
  119. package/dist/shared/interfaces/database.member-queries.d.ts +228 -0
  120. package/dist/shared/interfaces/database.member-queries.js +4 -0
  121. package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
  122. package/dist/shared/interfaces/database.negotiation.js +26 -0
  123. package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
  124. package/dist/shared/interfaces/database.network-queries.js +4 -0
  125. package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
  126. package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
  127. package/dist/shared/interfaces/database.port.d.ts +322 -0
  128. package/dist/shared/interfaces/database.port.js +29 -0
  129. package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
  130. package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
  131. package/package.json +2 -15
  132. package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
  133. package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
  134. package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
  135. package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
  136. package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
  137. package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
  138. package/dist/opportunities/domain/opportunity.presentation.js +0 -516
  139. package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
  140. package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Discovery pipeline, stages 4–5: evaluation and ranking.
3
+ *
4
+ * Evaluation builds an entity bundle from the discoverer plus the top candidates,
5
+ * asks the evaluator to score them, and maps the verdicts back onto graph state.
6
+ */
7
+ import { OpportunityEvaluator } from './opportunity.evaluator.js';
8
+ import { getModelName } from '../../shared/agent/model.config.js';
9
+ import { timed } from '../../shared/observability/performance.js';
10
+ import { requestContext } from '../../shared/observability/request-context.js';
11
+ import { getAbortSignalConfig } from '../../shared/agent/model-signal.js';
12
+ import { mergeOpportunityEvidence } from '../domain/opportunity.evidence.js';
13
+ import { DISCOVERY_EVALUATOR_MIN_SCORE_DEFAULT } from '../discovery.env.js';
14
+ import { buildEvaluatorEvidenceKey, buildNetworkContexts, evaluationLog, getRejectionCooldownMs, networkMembershipPairKey, rankingLog, REJECTION_COOLDOWN_SIMILARITY_PENALTY, safeOpportunityGraphError } from './opportunity.graph.shared.js';
15
+ /** Batch size for one evaluator call. Larger batches time out. */
16
+ const EVAL_BATCH_SIZE = 25;
17
+ /**
18
+ * Node 3: Evaluation (Entity bundle)
19
+ * Builds entity bundle from source + candidates, invokes entity-bundle evaluator, maps to EvaluatedOpportunity with networkId from entities.
20
+ */
21
+ export async function evaluationNode(state, deps) {
22
+ return timed("OpportunityGraph.evaluation", async () => {
23
+ const startTime = Date.now();
24
+ evaluationLog.verbose('Starting evaluation', {
25
+ candidatesCount: state.candidates.length,
26
+ });
27
+ if (state.candidates.length === 0) {
28
+ evaluationLog.verbose('No candidates to evaluate');
29
+ return { evaluatedOpportunities: [], agentTimings: [] };
30
+ }
31
+ const sortedCandidates = [...state.candidates].sort((a, b) => b.similarity - a.similarity);
32
+ const dedupedCandidates = dedupeCandidatesByUser(sortedCandidates, state);
33
+ const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
34
+ let eligibleCandidates;
35
+ try {
36
+ eligibleCandidates = await filterToActiveMemberships(dedupedCandidates, discoveryUserId, deps);
37
+ }
38
+ catch (error) {
39
+ evaluationLog.error('Active network membership recheck failed; skipping evaluation', { error });
40
+ return {
41
+ candidates: [],
42
+ evaluatedOpportunities: [],
43
+ remainingCandidates: [],
44
+ error: 'Failed to validate candidate network memberships.',
45
+ agentTimings: [],
46
+ };
47
+ }
48
+ if (eligibleCandidates.length < dedupedCandidates.length) {
49
+ evaluationLog.info('Removed candidates without active network pairs before evaluation', {
50
+ before: dedupedCandidates.length,
51
+ after: eligibleCandidates.length,
52
+ removed: dedupedCandidates.length - eligibleCandidates.length,
53
+ });
54
+ }
55
+ if (eligibleCandidates.length === 0) {
56
+ return { candidates: [], evaluatedOpportunities: [], remainingCandidates: [], agentTimings: [] };
57
+ }
58
+ if (dedupedCandidates.length < sortedCandidates.length) {
59
+ evaluationLog.info("Deduped candidates by userId", {
60
+ before: sortedCandidates.length,
61
+ after: dedupedCandidates.length,
62
+ removed: sortedCandidates.length - dedupedCandidates.length,
63
+ });
64
+ }
65
+ const eligibleCandidatesAfterCooldown = await applyRejectionCooldown(eligibleCandidates, discoveryUserId, deps);
66
+ const batchToEvaluate = eligibleCandidatesAfterCooldown.slice(0, EVAL_BATCH_SIZE);
67
+ const remaining = eligibleCandidatesAfterCooldown.slice(EVAL_BATCH_SIZE);
68
+ // Early termination: if search was query-driven and no query-sourced candidates remain,
69
+ // clear remaining to prevent pointless pagination through non-query leftovers
70
+ const isQueryDriven = !!state.searchQuery?.trim();
71
+ const queryRemaining = remaining.filter((c) => c.discoverySource === 'query' || c.discoverySource == null);
72
+ const effectiveRemaining = isQueryDriven && queryRemaining.length === 0 ? [] : remaining;
73
+ if (isQueryDriven && remaining.length > 0 && queryRemaining.length === 0) {
74
+ evaluationLog.info("Early termination: no query-sourced candidates remain", {
75
+ droppedCandidates: remaining.length,
76
+ });
77
+ }
78
+ if (effectiveRemaining.length > 0) {
79
+ evaluationLog.verbose('Batched candidates for evaluation', {
80
+ evaluating: batchToEvaluate.length,
81
+ remaining: effectiveRemaining.length,
82
+ total: sortedCandidates.length,
83
+ });
84
+ }
85
+ const agentTimingsAccum = [];
86
+ try {
87
+ const sourceProfile = await deps.database.getProfile(discoveryUserId);
88
+ const sourceEntity = {
89
+ userId: discoveryUserId,
90
+ profile: {
91
+ name: sourceProfile?.identity?.name,
92
+ bio: sourceProfile?.identity?.bio,
93
+ location: sourceProfile?.identity?.location,
94
+ context: sourceProfile?.context,
95
+ },
96
+ intents: state.indexedIntents.slice(0, 5).map((i) => ({
97
+ intentId: i.intentId,
98
+ payload: i.payload,
99
+ summary: i.summary,
100
+ })),
101
+ networkId: '', // Placeholder — overwritten per-pairing below
102
+ evidenceKey: `${discoveryUserId}::source`,
103
+ ragScore: undefined,
104
+ matchedVia: undefined,
105
+ };
106
+ const candidateEntities = await buildCandidateEntities(batchToEvaluate, deps);
107
+ const userIdToIndexId = new Map();
108
+ const evidenceByEntityKey = new Map();
109
+ const entityKeysByUserId = new Map();
110
+ for (const e of candidateEntities) {
111
+ if (!userIdToIndexId.has(e.userId))
112
+ userIdToIndexId.set(e.userId, e.networkId);
113
+ if (e.evidenceKey) {
114
+ evidenceByEntityKey.set(e.evidenceKey, mergeOpportunityEvidence(evidenceByEntityKey.get(e.evidenceKey), e.evidence));
115
+ entityKeysByUserId.set(e.userId, [...(entityKeysByUserId.get(e.userId) ?? []), e.evidenceKey]);
116
+ }
117
+ }
118
+ function evidenceForActor(actor) {
119
+ if (actor.evidenceKey)
120
+ return evidenceByEntityKey.get(actor.evidenceKey);
121
+ const keys = entityKeysByUserId.get(actor.userId) ?? [];
122
+ const intentKey = actor.intentId ? keys.find((key) => key.endsWith(`:${actor.intentId}`)) : undefined;
123
+ if (intentKey)
124
+ return evidenceByEntityKey.get(intentKey);
125
+ // Avoid leaking unrelated resource evidence when the evaluator collapsed multiple
126
+ // candidates for the same user into a profile-only actor.
127
+ if (keys.length === 1)
128
+ return evidenceByEntityKey.get(keys[0]);
129
+ return undefined;
130
+ }
131
+ const minScore = state.targetUserId
132
+ ? DISCOVERY_EVALUATOR_MIN_SCORE_DEFAULT
133
+ : deps.evaluatorMinScore;
134
+ const evaluatorSignalConfig = getAbortSignalConfig();
135
+ const evaluator = typeof deps.evaluatorAgent.invokeEntityBundle === 'function'
136
+ ? deps.evaluatorAgent
137
+ : new OpportunityEvaluator();
138
+ const runParallel = process.env.RUN_OPPORTUNITY_EVAL_IN_PARALLEL === 'true';
139
+ const networkContexts = await buildNetworkContexts([sourceEntity, ...candidateEntities], deps.database);
140
+ // Declare trace entries early so both parallel and serial paths can push error entries
141
+ const traceEntries = [];
142
+ const pairwiseOpportunities = runParallel
143
+ ? await evaluateInParallel({
144
+ evaluator, sourceEntity, candidateEntities, state, discoveryUserId,
145
+ networkContexts, minScore, evaluatorSignalConfig, agentTimingsAccum, traceEntries,
146
+ })
147
+ : splitBundledVerdicts(await evaluateBundled({
148
+ evaluator, sourceEntity, candidateEntities, state, discoveryUserId,
149
+ networkContexts, minScore, evaluatorSignalConfig, agentTimingsAccum,
150
+ }), state, candidateEntities);
151
+ const evaluatedOpportunities = pairwiseOpportunities.map((op) => ({
152
+ reasoning: op.reasoning,
153
+ score: op.score,
154
+ evidence: mergeOpportunityEvidence(...op.actors.map(evidenceForActor)),
155
+ actors: op.actors.map((a) => {
156
+ const isSource = a.userId === discoveryUserId;
157
+ if (isSource) {
158
+ // Source actor inherits the counterpart's networkId (shared match context)
159
+ const counterpart = op.actors.find((other) => other.userId !== a.userId);
160
+ const counterpartIndexId = counterpart
161
+ ? userIdToIndexId.get(counterpart.userId) ?? candidateEntities.find((e) => e.userId === counterpart.userId)?.networkId
162
+ : undefined;
163
+ return {
164
+ userId: a.userId,
165
+ role: a.role,
166
+ intentId: a.intentId,
167
+ networkId: counterpartIndexId ?? userIdToIndexId.get(a.userId) ?? '',
168
+ };
169
+ }
170
+ return {
171
+ userId: a.userId,
172
+ role: a.role,
173
+ intentId: a.intentId,
174
+ networkId: userIdToIndexId.get(a.userId) ?? candidateEntities.find((e) => e.userId === a.userId)?.networkId,
175
+ };
176
+ }),
177
+ }));
178
+ const passed = evaluatedOpportunities.filter((o) => o.score >= minScore);
179
+ evaluationLog.verbose('Evaluation complete', {
180
+ evaluatedCount: evaluatedOpportunities.length,
181
+ passed: passed.length,
182
+ });
183
+ // Threshold filter trace: how many candidates in this batch were above/below similarity threshold
184
+ const aboveThreshold = batchToEvaluate.filter((candidate) => candidate.similarity >= deps.retrievalMinSimilarity).length;
185
+ const belowThreshold = batchToEvaluate.length - aboveThreshold;
186
+ traceEntries.push({
187
+ node: "threshold_filter",
188
+ detail: `${aboveThreshold} above ${deps.retrievalMinSimilarity}, ${belowThreshold} below (batch of ${batchToEvaluate.length})`,
189
+ data: {
190
+ aboveThreshold,
191
+ belowThreshold,
192
+ minScore: deps.retrievalMinSimilarity,
193
+ retrievalMinSimilarity: deps.retrievalMinSimilarity,
194
+ evaluatorMinScore: minScore,
195
+ batchSize: batchToEvaluate.length,
196
+ },
197
+ });
198
+ // Create a map of evaluated candidates by userId for quick lookup.
199
+ // Use discoveryUserId (which accounts for onBehalfOfUserId in introducer flow)
200
+ // rather than state.userId (which is the introducer, not present in pairwise actors).
201
+ const evaluatedByUserId = new Map();
202
+ for (const opp of evaluatedOpportunities) {
203
+ const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
204
+ if (candidateActor) {
205
+ evaluatedByUserId.set(candidateActor.userId, { score: opp.score, reasoning: opp.reasoning });
206
+ }
207
+ }
208
+ traceEntries.push({
209
+ node: "evaluation",
210
+ detail: `Evaluated ${candidateEntities.length} candidate(s) → ${passed.length} passed (min score ${minScore})`,
211
+ data: {
212
+ inputCandidates: batchToEvaluate.length,
213
+ returnedFromEvaluator: evaluatedOpportunities.length,
214
+ passedCount: passed.length,
215
+ minScore,
216
+ remaining: effectiveRemaining.length,
217
+ batchNumber: 1,
218
+ durationMs: Date.now() - startTime,
219
+ model: getModelName("opportunityEvaluator"),
220
+ },
221
+ });
222
+ // Individual candidate entries - show ALL candidates that went to evaluator
223
+ for (const entity of candidateEntities) {
224
+ const candidateName = entity.profile?.name || entity.userId.slice(0, 8);
225
+ const evaluated = evaluatedByUserId.get(entity.userId);
226
+ const score = evaluated?.score;
227
+ const didPass = score !== undefined && score >= minScore;
228
+ const status = score !== undefined
229
+ ? (didPass ? '✓ passed' : `✗ score ${score}`)
230
+ : '✗ not scored';
231
+ traceEntries.push({
232
+ node: "candidate",
233
+ detail: `${candidateName}: ${status}`,
234
+ data: {
235
+ userId: entity.userId,
236
+ name: candidateName,
237
+ bio: entity.profile?.bio,
238
+ score: score,
239
+ passed: didPass,
240
+ reasoning: evaluated?.reasoning || 'No evaluation returned for this candidate',
241
+ matchedVia: entity.matchedVia,
242
+ ragScore: entity.ragScore,
243
+ model: getModelName("opportunityEvaluator"),
244
+ intents: entity.intents?.map((i) => ({
245
+ intentId: i.intentId,
246
+ summary: (i.summary || i.payload || '').slice(0, 100),
247
+ })),
248
+ profile: entity.profile ? {
249
+ name: entity.profile.name,
250
+ location: entity.profile.location,
251
+ } : undefined,
252
+ },
253
+ });
254
+ }
255
+ return {
256
+ candidates: eligibleCandidates,
257
+ // Only pass opportunities that passed the threshold to downstream nodes
258
+ evaluatedOpportunities: evaluatedOpportunities.filter((o) => o.score >= minScore),
259
+ remainingCandidates: effectiveRemaining,
260
+ trace: traceEntries,
261
+ agentTimings: agentTimingsAccum,
262
+ };
263
+ }
264
+ catch (error) {
265
+ const errMsg = safeOpportunityGraphError(error);
266
+ evaluationLog.error('Failed', { error: errMsg });
267
+ return {
268
+ evaluatedOpportunities: [],
269
+ error: 'Failed to evaluate candidates.',
270
+ trace: [{
271
+ node: "evaluation_fatal",
272
+ detail: `Evaluation failed: ${errMsg}`,
273
+ data: {
274
+ error: errMsg,
275
+ candidateCount: state.candidates?.length ?? 0,
276
+ durationMs: Date.now() - startTime,
277
+ },
278
+ }],
279
+ agentTimings: agentTimingsAccum,
280
+ };
281
+ }
282
+ });
283
+ }
284
+ /** Dedup by userId — when same similarity, prefer index with highest relevancyScore. */
285
+ function dedupeCandidatesByUser(sortedCandidates, state) {
286
+ const bestByUser = new Map();
287
+ for (const c of sortedCandidates) {
288
+ const existing = bestByUser.get(c.candidateUserId);
289
+ if (!existing) {
290
+ bestByUser.set(c.candidateUserId, c);
291
+ }
292
+ else if (c.similarity > existing.similarity) {
293
+ bestByUser.set(c.candidateUserId, c);
294
+ }
295
+ else if (c.similarity === existing.similarity) {
296
+ // Tie-break: prefer index with higher relevancy score
297
+ const cScore = state.indexRelevancyScores[c.networkId] ?? 0;
298
+ const existingScore = state.indexRelevancyScores[existing.networkId] ?? 0;
299
+ if (cScore > existingScore) {
300
+ bestByUser.set(c.candidateUserId, c);
301
+ }
302
+ }
303
+ }
304
+ const deduped = Array.from(bestByUser.values());
305
+ // Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
306
+ deduped.sort((a, b) => b.similarity - a.similarity);
307
+ return deduped;
308
+ }
309
+ /** Both sides of every pairing must still hold an active membership in the shared network. */
310
+ async function filterToActiveMemberships(dedupedCandidates, discoveryUserId, deps) {
311
+ const requestedPairs = dedupedCandidates.flatMap((candidate) => [
312
+ { userId: discoveryUserId, networkId: candidate.networkId },
313
+ { userId: candidate.candidateUserId, networkId: candidate.networkId },
314
+ ]);
315
+ const activePairs = await deps.database.getActiveNetworkMembershipPairs(requestedPairs);
316
+ const activePairKeys = new Set(activePairs.map((pair) => networkMembershipPairKey(pair.userId, pair.networkId)));
317
+ return dedupedCandidates.filter((candidate) => activePairKeys.has(networkMembershipPairKey(discoveryUserId, candidate.networkId))
318
+ && activePairKeys.has(networkMembershipPairKey(candidate.candidateUserId, candidate.networkId)));
319
+ }
320
+ /**
321
+ * IND-567: Rejection cool-down penalty.
322
+ *
323
+ * Candidates with a recently rejected or stalled opportunity receive a
324
+ * similarity penalty so they are ranked lower (and often pushed out of
325
+ * the evaluation batch). This prevents cross-query re-surfacing of
326
+ * false-positive matches that were already caught downstream.
327
+ * The persist-node dedup is still the hard gate; this is a soft guard
328
+ * that reduces evaluator cost and LLM false-positive rate.
329
+ */
330
+ async function applyRejectionCooldown(eligibleCandidates, discoveryUserId, deps) {
331
+ const rejectionCooldownIds = new Set();
332
+ if (eligibleCandidates.length > 0
333
+ && typeof deps.database.getRecentlyRejectedOpportunityCounterparties === 'function') {
334
+ try {
335
+ const cooldownMs = getRejectionCooldownMs();
336
+ const ids = await deps.database.getRecentlyRejectedOpportunityCounterparties(discoveryUserId, eligibleCandidates.map((c) => c.candidateUserId), cooldownMs);
337
+ for (const id of ids)
338
+ rejectionCooldownIds.add(id);
339
+ if (rejectionCooldownIds.size > 0) {
340
+ evaluationLog.info('IND-567 rejection cool-down: applying similarity penalty', {
341
+ affectedCount: rejectionCooldownIds.size,
342
+ cooldownDays: Math.round(cooldownMs / (24 * 60 * 60 * 1000)),
343
+ penalty: REJECTION_COOLDOWN_SIMILARITY_PENALTY,
344
+ });
345
+ }
346
+ }
347
+ catch (err) {
348
+ evaluationLog.warn('IND-567 rejection cool-down: lookup failed, skipping penalty', {
349
+ error: safeOpportunityGraphError(err),
350
+ });
351
+ }
352
+ }
353
+ // Apply penalty and re-sort so penalised candidates fall to the back.
354
+ return rejectionCooldownIds.size > 0
355
+ ? eligibleCandidates
356
+ .map((c) => rejectionCooldownIds.has(c.candidateUserId)
357
+ ? { ...c, similarity: c.similarity * REJECTION_COOLDOWN_SIMILARITY_PENALTY }
358
+ : c)
359
+ .sort((a, b) => b.similarity - a.similarity)
360
+ : eligibleCandidates;
361
+ }
362
+ /** Hydrate each candidate into the profile/intent shape the evaluator reads. */
363
+ async function buildCandidateEntities(batchToEvaluate, deps) {
364
+ return Promise.all(batchToEvaluate.map(async (c) => {
365
+ const profile = await deps.database.getProfile(c.candidateUserId);
366
+ let intentPayload = c.candidatePayload;
367
+ let intentSummary = c.candidateSummary;
368
+ let evidence = c.evidence;
369
+ if (c.candidateIntentId != null && (!intentPayload || intentPayload === '')) {
370
+ const intent = await deps.database.getIntent(c.candidateIntentId);
371
+ if (intent) {
372
+ intentPayload = intent.payload;
373
+ intentSummary = intent.summary ?? undefined;
374
+ }
375
+ }
376
+ if (c.candidatePremiseId != null && (!intentPayload || intentPayload === '')) {
377
+ const premise = await deps.database.getPremise(c.candidatePremiseId);
378
+ if (premise) {
379
+ intentPayload = premise.assertion.text;
380
+ intentSummary = premise.assertion.summary;
381
+ evidence = (c.evidence ?? []).map((item) => item.candidatePremiseId === c.candidatePremiseId
382
+ ? { ...item, payload: premise.assertion.text, summary: premise.assertion.summary, assertionText: premise.assertion.text }
383
+ : item);
384
+ }
385
+ }
386
+ return {
387
+ userId: c.candidateUserId,
388
+ profile: {
389
+ name: profile?.identity?.name,
390
+ bio: profile?.identity?.bio,
391
+ location: profile?.identity?.location,
392
+ context: profile?.context,
393
+ },
394
+ intents: c.candidateIntentId != null
395
+ ? [{ intentId: c.candidateIntentId, payload: intentPayload ?? '', summary: intentSummary }]
396
+ : undefined,
397
+ networkId: c.networkId,
398
+ evidenceKey: buildEvaluatorEvidenceKey(c),
399
+ ragScore: c.similarity * 100,
400
+ matchedVia: c.lens,
401
+ evidence,
402
+ };
403
+ }));
404
+ }
405
+ /** Build the evaluator input for a given entity set. */
406
+ function buildEvaluatorInput(args, entities) {
407
+ return {
408
+ discovererId: args.discoveryUserId,
409
+ entities,
410
+ existingOpportunities: args.state.options.existingOpportunities,
411
+ ...(args.state.searchQuery?.trim() ? { discoveryQuery: args.state.searchQuery.trim() } : {}),
412
+ networkContexts: args.networkContexts,
413
+ };
414
+ }
415
+ /** Experimental: one LLM call per candidate, all fired in parallel. */
416
+ async function evaluateInParallel(args) {
417
+ const { evaluator, sourceEntity, candidateEntities, minScore, evaluatorSignalConfig, agentTimingsAccum, traceEntries } = args;
418
+ evaluationLog.verbose('Running parallel evaluation', { candidates: candidateEntities.length });
419
+ const parallelErrors = [];
420
+ const parallelResults = await Promise.all(candidateEntities.map((candidateEntity) => {
421
+ const input = buildEvaluatorInput(args, [sourceEntity, candidateEntity]);
422
+ const _evalStart = Date.now();
423
+ const _traceEmitter = requestContext.getStore()?.traceEmitter;
424
+ _traceEmitter?.({ type: "agent_start", name: "opportunity-evaluator" });
425
+ const _candidateName = candidateEntity.profile?.name ?? "Unknown";
426
+ return evaluator.invokeEntityBundle(input, { minScore, returnAll: true, ...evaluatorSignalConfig })
427
+ .then((res) => {
428
+ const _evalDuration = Date.now() - _evalStart;
429
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
430
+ const _topScore = res.length > 0 ? Math.max(...res.map(r => r.score)) : -1;
431
+ const _summary = _topScore < 0 ? `${_candidateName}: no match` : `${_candidateName}: ${_topScore}`;
432
+ _traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: _summary });
433
+ return res;
434
+ })
435
+ .catch((err) => {
436
+ const _evalDuration = Date.now() - _evalStart;
437
+ const _errMsg = safeOpportunityGraphError(err);
438
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
439
+ _traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `${_candidateName}: error — ${_errMsg}` });
440
+ evaluationLog.warn('Parallel eval failed for candidate', {
441
+ candidateUserId: candidateEntity.userId,
442
+ error: _errMsg,
443
+ });
444
+ parallelErrors.push({
445
+ candidateUserId: candidateEntity.userId,
446
+ candidateName: _candidateName,
447
+ error: _errMsg,
448
+ durationMs: _evalDuration,
449
+ });
450
+ return [];
451
+ });
452
+ }));
453
+ // Record trace entries for candidates that failed during parallel evaluation
454
+ if (parallelErrors.length > 0) {
455
+ traceEntries.push({
456
+ node: "evaluation_errors",
457
+ detail: `${parallelErrors.length}/${candidateEntities.length} candidate evaluation(s) failed`,
458
+ data: {
459
+ failedCount: parallelErrors.length,
460
+ totalCandidates: candidateEntities.length,
461
+ errors: parallelErrors.map(e => ({
462
+ candidateUserId: e.candidateUserId,
463
+ candidateName: e.candidateName,
464
+ error: e.error,
465
+ durationMs: e.durationMs,
466
+ })),
467
+ },
468
+ });
469
+ }
470
+ // Each call is already pairwise (source + 1 candidate) — flatten directly
471
+ return parallelResults.flat();
472
+ }
473
+ /** Default: single bundled LLM call with all candidates. */
474
+ async function evaluateBundled(args) {
475
+ const { evaluator, sourceEntity, candidateEntities, minScore, evaluatorSignalConfig, agentTimingsAccum } = args;
476
+ const input = buildEvaluatorInput(args, [sourceEntity, ...candidateEntities]);
477
+ // Get ALL scored results for tracing (returnAll: true), filter for persistence later
478
+ const _evalStart = Date.now();
479
+ const _traceEmitterSerial = requestContext.getStore()?.traceEmitter;
480
+ _traceEmitterSerial?.({ type: "agent_start", name: "opportunity-evaluator" });
481
+ try {
482
+ const opportunitiesWithActors = await evaluator.invokeEntityBundle(input, { minScore, returnAll: true, ...evaluatorSignalConfig });
483
+ const _evalDuration = Date.now() - _evalStart;
484
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
485
+ _traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `Evaluated ${candidateEntities.length} candidate(s)` });
486
+ return opportunitiesWithActors;
487
+ }
488
+ catch (serialErr) {
489
+ const _evalDuration = Date.now() - _evalStart;
490
+ const _errMsg = safeOpportunityGraphError(serialErr);
491
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
492
+ _traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `error — ${_errMsg}` });
493
+ throw serialErr; // Re-throw for the outer catch to handle
494
+ }
495
+ }
496
+ /**
497
+ * Split multi-actor evaluator results into pairwise (viewer + candidate).
498
+ * Each persisted discovery opportunity should have exactly 2 actors.
499
+ * When splitting, build per-candidate reasoning from entity data because
500
+ * the shared reasoning typically describes only one candidate.
501
+ */
502
+ function splitBundledVerdicts(opportunitiesWithActors, state, candidateEntities) {
503
+ const pairwiseOpportunities = [];
504
+ for (const op of opportunitiesWithActors) {
505
+ const pairwiseSourceId = state.onBehalfOfUserId ?? state.userId;
506
+ const nonViewerActors = op.actors.filter(a => a.userId !== pairwiseSourceId);
507
+ if (nonViewerActors.length <= 1) {
508
+ pairwiseOpportunities.push(op);
509
+ continue;
510
+ }
511
+ evaluationLog.warn('Splitting multi-actor opportunity; LLM returned bundled actors instead of one-per-candidate', {
512
+ actorCount: nonViewerActors.length,
513
+ userIds: nonViewerActors.map(a => a.userId),
514
+ });
515
+ const viewerActor = op.actors.find(a => a.userId === pairwiseSourceId);
516
+ for (const candidate of nonViewerActors) {
517
+ const entity = candidateEntities.find(e => e.userId === candidate.userId);
518
+ const candidateName = entity?.profile?.name ?? '';
519
+ const reasoningLower = op.reasoning.toLowerCase();
520
+ const mentionsCandidate = candidateName !== '' &&
521
+ reasoningLower.includes(candidateName.toLowerCase());
522
+ const mentionsOtherCandidate = nonViewerActors
523
+ .filter((actor) => actor.userId !== candidate.userId)
524
+ .map((actor) => candidateEntities.find((e) => e.userId === actor.userId)?.profile?.name?.toLowerCase())
525
+ .some((name) => name != null && reasoningLower.includes(name));
526
+ let reasoning;
527
+ if (mentionsCandidate && !mentionsOtherCandidate) {
528
+ reasoning = op.reasoning;
529
+ }
530
+ else if (entity?.profile) {
531
+ const p = entity.profile;
532
+ const parts = [p.name, p.bio].filter(Boolean);
533
+ if (p.skills?.length)
534
+ parts.push(`Skills: ${p.skills.join(', ')}`);
535
+ if (p.interests?.length)
536
+ parts.push(`Interests: ${p.interests.join(', ')}`);
537
+ reasoning = parts.join('. ') || op.reasoning;
538
+ }
539
+ else {
540
+ reasoning = op.reasoning;
541
+ }
542
+ pairwiseOpportunities.push({
543
+ reasoning,
544
+ score: op.score,
545
+ actors: [
546
+ viewerActor ?? { userId: pairwiseSourceId, role: 'patient', intentId: null },
547
+ candidate,
548
+ ],
549
+ });
550
+ }
551
+ }
552
+ return pairwiseOpportunities;
553
+ }
554
+ /**
555
+ * Node 4: Ranking
556
+ * Sorts evaluated opportunities by score, applies limit, dedupes by actor-set hash.
557
+ */
558
+ export async function rankingNode(state) {
559
+ return timed("OpportunityGraph.ranking", async () => {
560
+ rankingLog.verbose('Starting ranking', {
561
+ evaluatedCount: state.evaluatedOpportunities.length,
562
+ });
563
+ try {
564
+ const sorted = [...state.evaluatedOpportunities].sort((a, b) => b.score - a.score);
565
+ const limit = state.options.limit ?? 20;
566
+ const ranked = sorted.slice(0, limit);
567
+ const actorSetKey = (opp) => opp.actors
568
+ .map((a) => `${a.userId}:${a.networkId}`)
569
+ .sort()
570
+ .join('|');
571
+ const seen = new Set();
572
+ const deduplicated = ranked.filter((opp) => {
573
+ const key = actorSetKey(opp);
574
+ if (seen.has(key))
575
+ return false;
576
+ seen.add(key);
577
+ return true;
578
+ });
579
+ rankingLog.verbose('Ranking complete', {
580
+ sorted: sorted.length,
581
+ afterLimit: ranked.length,
582
+ afterDedup: deduplicated.length,
583
+ });
584
+ return { evaluatedOpportunities: deduplicated };
585
+ }
586
+ catch (error) {
587
+ const errMsg = error instanceof Error ? error.message : String(error);
588
+ rankingLog.error('Failed', { error });
589
+ return {
590
+ evaluatedOpportunities: [],
591
+ error: 'Failed to rank opportunities.',
592
+ trace: [{
593
+ node: "ranking_fatal",
594
+ detail: `Ranking failed: ${errMsg}`,
595
+ data: { error: errMsg },
596
+ }],
597
+ };
598
+ }
599
+ });
600
+ }
601
+ /** Trace summary for {@link rankingNode}. */
602
+ export function rankingTraceSummary(result) {
603
+ const r = result;
604
+ if (r?.error)
605
+ return `error: ${r.error}`;
606
+ const opps = r?.evaluatedOpportunities;
607
+ return opps ? `Ranked ${opps.length} opportunity(ies)` : undefined;
608
+ }