@neat.is/core 0.7.10 → 0.8.0

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.
@@ -3341,6 +3341,62 @@ function columnIsObserved(col) {
3341
3341
  return col.provenances.includes(Provenance4.OBSERVED);
3342
3342
  }
3343
3343
 
3344
+ // src/latency-digest.ts
3345
+ var SUB = 16;
3346
+ var MIN_EXP = -10;
3347
+ var MAX_EXP = 22;
3348
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
3349
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
3350
+ function latencyBucketIndex(ms) {
3351
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
3352
+ const e = Math.floor(Math.log2(ms));
3353
+ if (e < MIN_EXP) return 0;
3354
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
3355
+ const base = 2 ** e;
3356
+ const raw = Math.floor((ms / base - 1) * SUB);
3357
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
3358
+ return (e - MIN_EXP) * SUB + s + 1;
3359
+ }
3360
+ function bucketRepresentativeMs(index) {
3361
+ if (index <= 0) return 0;
3362
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
3363
+ const zeroBased = index - 1;
3364
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
3365
+ const s = zeroBased % SUB;
3366
+ const base = 2 ** e;
3367
+ const lower = base * (1 + s / SUB);
3368
+ const upper = base * (1 + (s + 1) / SUB);
3369
+ return (lower + upper) / 2;
3370
+ }
3371
+ function recordLatency(hist, ms) {
3372
+ const key = String(latencyBucketIndex(ms));
3373
+ hist[key] = (hist[key] ?? 0) + 1;
3374
+ return hist;
3375
+ }
3376
+ function quantile(hist, q) {
3377
+ const entries = Object.entries(hist).map(([k, c]) => [Number(k), c]).filter(([idx, c]) => Number.isFinite(idx) && c > 0).sort((a, b) => a[0] - b[0]);
3378
+ let total = 0;
3379
+ for (const [, c] of entries) total += c;
3380
+ if (total === 0) return 0;
3381
+ const rank = Math.max(1, Math.ceil(q * total));
3382
+ let cumulative = 0;
3383
+ for (const [idx, c] of entries) {
3384
+ cumulative += c;
3385
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
3386
+ }
3387
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
3388
+ }
3389
+ function round(ms) {
3390
+ return Math.round(ms * 100) / 100;
3391
+ }
3392
+ function latencyPercentiles(hist) {
3393
+ if (!hist) return void 0;
3394
+ let total = 0;
3395
+ for (const c of Object.values(hist)) total += c;
3396
+ if (total === 0) return void 0;
3397
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
3398
+ }
3399
+
3344
3400
  // src/ingest.ts
3345
3401
  var HOUR_MS = 60 * 60 * 1e3;
3346
3402
  var DAY_MS = 24 * HOUR_MS;
@@ -4036,7 +4092,7 @@ function ensureFrontierNode(graph, host, ts) {
4036
4092
  graph.addNode(id, node);
4037
4093
  return id;
4038
4094
  }
4039
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
4095
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
4040
4096
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
4041
4097
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
4042
4098
  const id = makeObservedEdgeId(type, source, target);
@@ -4044,10 +4100,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
4044
4100
  const existing = graph.getEdgeAttributes(id);
4045
4101
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
4046
4102
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
4103
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
4104
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
4047
4105
  const newSignal = {
4048
4106
  spanCount: newSpanCount,
4049
4107
  errorCount: newErrorCount,
4050
- lastObservedAgeMs: 0
4108
+ lastObservedAgeMs: 0,
4109
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
4110
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
4111
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
4051
4112
  };
4052
4113
  const updated = {
4053
4114
  ...existing,
@@ -4062,10 +4123,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
4062
4123
  graph.replaceEdgeAttributes(id, updated);
4063
4124
  return { edge: updated, created: false };
4064
4125
  }
4126
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
4127
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
4065
4128
  const signal = {
4066
4129
  spanCount: 1,
4067
4130
  errorCount: isError ? 1 : 0,
4068
- lastObservedAgeMs: 0
4131
+ lastObservedAgeMs: 0,
4132
+ ...latencyHist ? { latencyHist } : {},
4133
+ ...latencyMs ? { latencyMs } : {}
4069
4134
  };
4070
4135
  const edge = {
4071
4136
  id,
@@ -4290,6 +4355,7 @@ async function handleSpan(ctx, span) {
4290
4355
  }
4291
4356
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
4292
4357
  const isError = span.statusCode === 2;
4358
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
4293
4359
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
4294
4360
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
4295
4361
  cacheSpanService(span, nowMs, callSite);
@@ -4328,7 +4394,8 @@ async function handleSpan(ctx, span) {
4328
4394
  targetId,
4329
4395
  ts,
4330
4396
  isError,
4331
- callSiteEvidence
4397
+ callSiteEvidence,
4398
+ durationMs
4332
4399
  );
4333
4400
  if (result) affectedNode = targetId;
4334
4401
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -4340,7 +4407,8 @@ async function handleSpan(ctx, span) {
4340
4407
  collectionId,
4341
4408
  ts,
4342
4409
  isError,
4343
- callSiteEvidence
4410
+ callSiteEvidence,
4411
+ durationMs
4344
4412
  );
4345
4413
  }
4346
4414
  if (span.dbTable) {
@@ -4352,7 +4420,8 @@ async function handleSpan(ctx, span) {
4352
4420
  tableId,
4353
4421
  ts,
4354
4422
  isError,
4355
- callSiteEvidence
4423
+ callSiteEvidence,
4424
+ durationMs
4356
4425
  );
4357
4426
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
4358
4427
  }
@@ -4371,7 +4440,8 @@ async function handleSpan(ctx, span) {
4371
4440
  targetId,
4372
4441
  ts,
4373
4442
  isError,
4374
- callSiteEvidence
4443
+ callSiteEvidence,
4444
+ durationMs
4375
4445
  );
4376
4446
  if (result) affectedNode = targetId;
4377
4447
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -4388,7 +4458,8 @@ async function handleSpan(ctx, span) {
4388
4458
  targetId,
4389
4459
  ts,
4390
4460
  isError,
4391
- callSiteEvidence
4461
+ callSiteEvidence,
4462
+ durationMs
4392
4463
  );
4393
4464
  if (result) affectedNode = targetId;
4394
4465
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -4400,7 +4471,8 @@ async function handleSpan(ctx, span) {
4400
4471
  targetId,
4401
4472
  ts,
4402
4473
  isError,
4403
- callSiteEvidence
4474
+ callSiteEvidence,
4475
+ durationMs
4404
4476
  );
4405
4477
  if (result) affectedNode = targetId;
4406
4478
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -4416,7 +4488,8 @@ async function handleSpan(ctx, span) {
4416
4488
  targetId,
4417
4489
  ts,
4418
4490
  isError,
4419
- callSiteEvidence
4491
+ callSiteEvidence,
4492
+ durationMs
4420
4493
  );
4421
4494
  if (result) affectedNode = targetId;
4422
4495
  } else {
@@ -4432,7 +4505,8 @@ async function handleSpan(ctx, span) {
4432
4505
  targetId,
4433
4506
  ts,
4434
4507
  isError,
4435
- callSiteEvidence
4508
+ callSiteEvidence,
4509
+ durationMs
4436
4510
  );
4437
4511
  affectedNode = targetId;
4438
4512
  resolvedViaAddress = true;
@@ -4445,7 +4519,8 @@ async function handleSpan(ctx, span) {
4445
4519
  frontierNodeId,
4446
4520
  ts,
4447
4521
  isError,
4448
- callSiteEvidence
4522
+ callSiteEvidence,
4523
+ durationMs
4449
4524
  );
4450
4525
  affectedNode = frontierNodeId;
4451
4526
  resolvedViaAddress = true;
@@ -4471,7 +4546,8 @@ async function handleSpan(ctx, span) {
4471
4546
  sourceId,
4472
4547
  ts,
4473
4548
  isError,
4474
- fallbackEvidence
4549
+ fallbackEvidence,
4550
+ durationMs
4475
4551
  );
4476
4552
  }
4477
4553
  }
@@ -4485,7 +4561,7 @@ async function handleSpan(ctx, span) {
4485
4561
  );
4486
4562
  if (routeNodeId) {
4487
4563
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
4488
- upsertObservedEdge(ctx.graph, EdgeType5.CONTAINS, serviceId(routeSvc), routeNodeId, ts, isError);
4564
+ upsertObservedEdge(ctx.graph, EdgeType5.CONTAINS, serviceId(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
4489
4565
  }
4490
4566
  }
4491
4567
  if (span.statusCode === 2) {
@@ -4774,10 +4850,12 @@ function mergeSnapshot(graph, snapshot) {
4774
4850
  import {
4775
4851
  BlastRadiusResultSchema,
4776
4852
  EdgeType as EdgeType6,
4853
+ ExpandResultSchema,
4777
4854
  NodeType as NodeType5,
4778
4855
  ObservedDependenciesResultSchema,
4779
4856
  PROV_RANK,
4780
4857
  Provenance as Provenance6,
4858
+ RelateResultSchema,
4781
4859
  RootCauseResultSchema,
4782
4860
  TransitiveDependenciesResultSchema
4783
4861
  } from "@neat.is/types";
@@ -4994,7 +5072,7 @@ var rootCauseShapes = {
4994
5072
  [NodeType5.FileNode]: fileRootCauseShape,
4995
5073
  [NodeType5.SymbolNode]: symbolRootCauseShape
4996
5074
  };
4997
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
5075
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
4998
5076
  if (!graph.hasNode(errorNodeId)) return null;
4999
5077
  const origin = graph.getNodeAttributes(errorNodeId);
5000
5078
  const shape = rootCauseShapes[origin.type];
@@ -5251,7 +5329,9 @@ function getObservedDependencies(graph, nodeId) {
5251
5329
  dependencies: [],
5252
5330
  observed: false,
5253
5331
  inboundObservedCount: 0,
5254
- hasExtractedOutbound: false
5332
+ hasExtractedOutbound: false,
5333
+ inboundVolume: 0,
5334
+ window: "lifetime"
5255
5335
  });
5256
5336
  }
5257
5337
  const attrs = graph.getNodeAttributes(nodeId);
@@ -5282,11 +5362,19 @@ function getObservedDependencies(graph, nodeId) {
5282
5362
  }
5283
5363
  }
5284
5364
  let inboundObservedCount = 0;
5365
+ let inboundVolume = 0;
5366
+ let inboundLastObserved;
5285
5367
  for (const tgt of scope) {
5286
5368
  for (const edgeId of graph.inboundEdges(tgt)) {
5287
5369
  const e = graph.getEdgeAttributes(edgeId);
5288
5370
  if (e.type === EdgeType6.CONTAINS) continue;
5289
- if (e.provenance === Provenance6.OBSERVED) inboundObservedCount += 1;
5371
+ if (e.provenance === Provenance6.OBSERVED) {
5372
+ inboundObservedCount += 1;
5373
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
5374
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
5375
+ inboundLastObserved = e.lastObserved;
5376
+ }
5377
+ }
5290
5378
  }
5291
5379
  }
5292
5380
  dependencies.sort(
@@ -5297,7 +5385,291 @@ function getObservedDependencies(graph, nodeId) {
5297
5385
  dependencies,
5298
5386
  observed: dependencies.length > 0 || inboundObservedCount > 0,
5299
5387
  inboundObservedCount,
5300
- hasExtractedOutbound
5388
+ hasExtractedOutbound,
5389
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
5390
+ inboundVolume,
5391
+ window: "lifetime",
5392
+ ...inboundLastObserved ? { inboundLastObserved } : {}
5393
+ });
5394
+ }
5395
+ var SATURATION_P95_MS = 1e3;
5396
+ function nodeScope(graph, nodeId) {
5397
+ const scope = [nodeId];
5398
+ if (!graph.hasNode(nodeId)) return scope;
5399
+ const attrs = graph.getNodeAttributes(nodeId);
5400
+ if (attrs.type === NodeType5.ServiceNode) {
5401
+ for (const edgeId of graph.outboundEdges(nodeId)) {
5402
+ const e = graph.getEdgeAttributes(edgeId);
5403
+ if (e.type !== EdgeType6.CONTAINS) continue;
5404
+ const owned = graph.getNodeAttributes(e.target);
5405
+ if (owned.type === NodeType5.FileNode) scope.push(e.target);
5406
+ }
5407
+ }
5408
+ return scope;
5409
+ }
5410
+ function incidentCountForNode(nodeId, incidents) {
5411
+ if (!incidents || incidents.length === 0) return 0;
5412
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
5413
+ }
5414
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
5415
+ const scope = nodeScope(graph, nodeId);
5416
+ let errorsFromCallers = 0;
5417
+ let inboundVolume = 0;
5418
+ let outboundVolume = 0;
5419
+ let outboundErrors = 0;
5420
+ let latestInboundMs;
5421
+ let latencyP95Ms;
5422
+ let stale = false;
5423
+ for (const n of scope) {
5424
+ if (!graph.hasNode(n)) continue;
5425
+ for (const edgeId of graph.inboundEdges(n)) {
5426
+ const e = graph.getEdgeAttributes(edgeId);
5427
+ if (e.type === EdgeType6.CONTAINS) continue;
5428
+ errorsFromCallers += e.signal?.errorCount ?? 0;
5429
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
5430
+ if (e.provenance === Provenance6.STALE) stale = true;
5431
+ const p95 = e.signal?.latencyMs?.p95;
5432
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
5433
+ if (e.lastObserved) {
5434
+ const t = Date.parse(e.lastObserved);
5435
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
5436
+ }
5437
+ }
5438
+ for (const edgeId of graph.outboundEdges(n)) {
5439
+ const e = graph.getEdgeAttributes(edgeId);
5440
+ if (e.type === EdgeType6.CONTAINS) continue;
5441
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
5442
+ if (e.type === EdgeType6.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
5443
+ if (e.provenance === Provenance6.STALE) stale = true;
5444
+ }
5445
+ }
5446
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
5447
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
5448
+ return {
5449
+ errorsEmittedHere,
5450
+ errorsFromCallers,
5451
+ callCount: inboundVolume,
5452
+ outboundVolume,
5453
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
5454
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
5455
+ stale
5456
+ };
5457
+ }
5458
+ function isSaturated(ctx) {
5459
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
5460
+ }
5461
+ function classifyNode(ctx) {
5462
+ if (ctx.errorsEmittedHere > 0) {
5463
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
5464
+ return "symptom-only";
5465
+ }
5466
+ return "primary-failure";
5467
+ }
5468
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
5469
+ return "unrelated";
5470
+ }
5471
+ function isVictimSeed(ctx) {
5472
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
5473
+ }
5474
+ function grainOf(graph, nodeId) {
5475
+ if (!graph.hasNode(nodeId)) return "unknown";
5476
+ const t = graph.getNodeAttributes(nodeId).type;
5477
+ if (t === NodeType5.ServiceNode) return "service";
5478
+ if (t === NodeType5.FileNode) return "file";
5479
+ if (t === NodeType5.SymbolNode) return "symbol";
5480
+ return t;
5481
+ }
5482
+ function findPath(graph, from, to, direction, maxDepth) {
5483
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
5484
+ if (from === to) return { nodes: [from], edges: [] };
5485
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
5486
+ const enqueued = /* @__PURE__ */ new Set([from]);
5487
+ while (queue.length > 0) {
5488
+ const frame = queue.shift();
5489
+ if (frame.depth >= maxDepth) continue;
5490
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
5491
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
5492
+ for (const [nid, edge] of neighbours) {
5493
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
5494
+ if (enqueued.has(nid)) continue;
5495
+ enqueued.add(nid);
5496
+ queue.push({
5497
+ nodeId: nid,
5498
+ depth: frame.depth + 1,
5499
+ nodes: [...frame.nodes, nid],
5500
+ edges: [...frame.edges, edge]
5501
+ });
5502
+ }
5503
+ }
5504
+ return null;
5505
+ }
5506
+ var EMPTY_CONTEXT = {
5507
+ errorsEmittedHere: 0,
5508
+ errorsFromCallers: 0,
5509
+ callCount: 0,
5510
+ outboundVolume: 0,
5511
+ stale: false
5512
+ };
5513
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
5514
+ if (!graph.hasNode(nodeId)) {
5515
+ return ExpandResultSchema.parse({
5516
+ origin: nodeId,
5517
+ direction,
5518
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
5519
+ neighbours: []
5520
+ });
5521
+ }
5522
+ const ctx = nodeContext(graph, nodeId, incidents, now);
5523
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
5524
+ const neighbours = [];
5525
+ for (const [nid, edge] of best) {
5526
+ if (edge.type === EdgeType6.CONTAINS) continue;
5527
+ const nctx = nodeContext(graph, nid, incidents, now);
5528
+ neighbours.push({
5529
+ node: nid,
5530
+ edgeType: edge.type,
5531
+ provenance: edge.provenance,
5532
+ classification: classifyNode(nctx),
5533
+ context: nctx
5534
+ });
5535
+ }
5536
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
5537
+ return ExpandResultSchema.parse({
5538
+ origin: nodeId,
5539
+ direction,
5540
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
5541
+ neighbours
5542
+ });
5543
+ }
5544
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
5545
+ const buildPath = (fp) => ({
5546
+ nodes: fp.nodes,
5547
+ edgeTypes: fp.edges.map((e) => e.type),
5548
+ provenance: fp.edges.map((e) => e.provenance),
5549
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
5550
+ // The failure runs end to end when every hop carries error / latency / alert
5551
+ // signal — that is what turns reachability into cause-confirmation.
5552
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
5553
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
5554
+ )
5555
+ });
5556
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
5557
+ return RelateResultSchema.parse({
5558
+ a,
5559
+ b,
5560
+ related: false,
5561
+ direction: null,
5562
+ paths: [],
5563
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
5564
+ });
5565
+ }
5566
+ const down = findPath(graph, a, b, "down", maxDepth);
5567
+ const up = findPath(graph, a, b, "up", maxDepth);
5568
+ if (!down && !up) {
5569
+ return RelateResultSchema.parse({
5570
+ a,
5571
+ b,
5572
+ related: false,
5573
+ direction: null,
5574
+ paths: [],
5575
+ note: `no path within ${maxDepth} hops`
5576
+ });
5577
+ }
5578
+ const paths = [];
5579
+ const direction = down ? "a->b" : "b->a";
5580
+ if (down) paths.push(buildPath(down));
5581
+ if (up) paths.push(buildPath(up));
5582
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
5583
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
5584
+ return RelateResultSchema.parse({
5585
+ a,
5586
+ b,
5587
+ related: true,
5588
+ direction,
5589
+ paths,
5590
+ ...grainGap ? { grainGap: true } : {}
5591
+ });
5592
+ }
5593
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
5594
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
5595
+ let best = null;
5596
+ for (const nid of upstream) {
5597
+ if (nid === alertNodeId) continue;
5598
+ const ctx = nodeContext(graph, nid, incidents, now);
5599
+ if (ctx.outboundVolume === 0) continue;
5600
+ const isSource = ctx.callCount === 0;
5601
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
5602
+ if (better) best = { node: nid, ctx, isSource };
5603
+ }
5604
+ return best ? { node: best.node, ctx: best.ctx } : null;
5605
+ }
5606
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
5607
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
5608
+ if (!legacy) return null;
5609
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
5610
+ if (!navigation) return legacy;
5611
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
5612
+ }
5613
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
5614
+ const seedNode = legacy.rootCauseNode;
5615
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
5616
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
5617
+ const candidates = [];
5618
+ if (seedCtx && isVictimSeed(seedCtx)) {
5619
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
5620
+ if (origin) {
5621
+ const path64 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
5622
+ const originConfidence = confidenceFromMix(path64?.edges ?? [], now);
5623
+ candidates.push({
5624
+ node: origin.node,
5625
+ classification: "primary-failure",
5626
+ reason: `Highest-volume upstream source (${origin.ctx.outboundVolume} observed outbound calls) driving a saturated/stale subgraph; the alerting path decays downstream into a starved victim rather than a fault at the callee.`,
5627
+ context: origin.ctx,
5628
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
5629
+ provenance: Provenance6.OBSERVED
5630
+ });
5631
+ }
5632
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
5633
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
5634
+ candidates.push({
5635
+ node: seedNode,
5636
+ classification: "symptom-only",
5637
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
5638
+ context: seedCtx,
5639
+ confidence: Math.min(legacy.confidence, 0.4),
5640
+ ...lastProv ? { provenance: lastProv } : {}
5641
+ });
5642
+ } else {
5643
+ candidates.push({
5644
+ node: seedNode,
5645
+ classification: "primary-failure",
5646
+ reason: legacy.rootCauseReason,
5647
+ context: seedCtx ?? EMPTY_CONTEXT,
5648
+ confidence: legacy.confidence,
5649
+ ...lastProv ? { provenance: lastProv } : {}
5650
+ });
5651
+ }
5652
+ const top = candidates[0];
5653
+ let traversalPath = legacy.traversalPath;
5654
+ let edgeProvenances = legacy.edgeProvenances;
5655
+ if (top.node !== seedNode) {
5656
+ const path64 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
5657
+ if (path64) {
5658
+ traversalPath = path64.nodes;
5659
+ edgeProvenances = path64.edges.map((e) => e.provenance);
5660
+ } else {
5661
+ traversalPath = [errorNodeId, top.node];
5662
+ edgeProvenances = [top.provenance ?? Provenance6.OBSERVED];
5663
+ }
5664
+ }
5665
+ return RootCauseResultSchema.parse({
5666
+ rootCauseNode: top.node,
5667
+ rootCauseReason: top.reason,
5668
+ traversalPath,
5669
+ edgeProvenances,
5670
+ confidence: top.confidence,
5671
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
5672
+ candidates
5301
5673
  });
5302
5674
  }
5303
5675
 
@@ -17956,6 +18328,34 @@ function registerRoutes(scope, ctx) {
17956
18328
  }
17957
18329
  return getBlastRadius(proj.graph, nodeId, depth);
17958
18330
  });
18331
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
18332
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18333
+ if (!proj) return;
18334
+ const { nodeId } = req.params;
18335
+ if (!proj.graph.hasNode(nodeId)) {
18336
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18337
+ }
18338
+ const direction = req.query.direction;
18339
+ if (direction !== "up" && direction !== "down") {
18340
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18341
+ }
18342
+ const epath = errorsPathFor(proj);
18343
+ const incidents = epath ? await readErrorEvents(epath) : [];
18344
+ return expandNode(proj.graph, nodeId, direction, incidents);
18345
+ });
18346
+ scope.get("/graph/relate", async (req, reply) => {
18347
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18348
+ if (!proj) return;
18349
+ const { a, b } = req.query;
18350
+ if (!a || !b) {
18351
+ return reply.code(400).send({ error: "both a and b query params are required" });
18352
+ }
18353
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
18354
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18355
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18356
+ }
18357
+ return relate(proj.graph, a, b, maxDepth);
18358
+ });
17959
18359
  scope.get("/search", async (req, reply) => {
17960
18360
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
17961
18361
  if (!proj) return;
@@ -18449,8 +18849,8 @@ export {
18449
18849
  emitNeatEvent,
18450
18850
  attachGraphToEventBus,
18451
18851
  confidenceForEdge,
18452
- getRootCause,
18453
18852
  getBlastRadius,
18853
+ getRootCause,
18454
18854
  evaluateAllPolicies,
18455
18855
  loadPolicyFile,
18456
18856
  PolicyViolationsLog,
@@ -18524,4 +18924,4 @@ export {
18524
18924
  deprovisionConnector,
18525
18925
  buildApi
18526
18926
  };
18527
- //# sourceMappingURL=chunk-6T7ZHODF.js.map
18927
+ //# sourceMappingURL=chunk-3PXRQH53.js.map