[GH-ISSUE #5906] [Bug] build_call_path counts terminal-goal outgoing edges as explored #3585

Closed
opened 2026-10-31 00:42:07 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5906

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

build_call_path includes the terminal goal's outgoing relationships in explored.edges, although its forward search does not expand that goal. Reproduced through public MCP using synthetic, valid analyze_function Evidence and the repository's in-memory session harness.

With 0x1000 → 0x4000, the result reports one explored edge. Adding a dossier for 0x4000 → 0x5000 changes that count to two while explored nodes and depth remain two and one. A zero-hop search from 0x1000 to itself reports one explored edge at depth zero when its dossier has one callee.

Reproduction

At the revision below, save this test as tests/boundary/mcp/callPathGoalEdges.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/boundary/mcp/callPathGoalEdges.test.ts --maxWorkers 1

This is the complete test executed, with only the filename changed. It creates authenticated fixture Evidence, records it in a session, and calls the registered MCP tool. No native analysis provider or binary execution is involved.

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { callPathResultSchema } from "../../../src/domain/callPathSchemas.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";

const observe = (address: string, callees: string[]) => {
  const base = FUNCTION_COMPARISON_EXAMPLE.left;
  if (base.subject === null) throw new Error("Missing fixture subject");
  const dossier = functionDossierSchema.parse(base.normalized_result);
  return createEvidence(
    { path: base.subject.local_path, sha256: base.subject.digest.sha256,
      format: base.subject.format },
    base.provider,
    { operation: "analyze_function", parameters: { procedure: address },
      result: { ...dossier,
        procedure: { ...dossier.procedure, address },
        callees: callees.map((callee) => ({ address: callee, name: `fn_${callee}`,
          classification: null, body: dossier.procedure.body })),
        callers: [], unresolved_calls: [], limitations: [] },
      confidence: base.confidence, authority: base.authority,
      relationshipCoverage: { status: "complete" } },
  );
};

it("counts only edges expanded before the terminal goal", async () => {
  const session = createTestBinarySession(() => {
    throw new Error("Offline reconstruction must not launch a provider");
  });
  const server = createServer({ kind: "session", session });
  const client = new Client({ name: "call-path-edge-reproduction", version: "1" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    const start = observe("0x1000", ["0x4000"]);
    const goal = observe("0x4000", ["0x5000"]);
    for (const evidence of [start, goal])
      expect(session.recordEvidence(evidence).ok).toBe(true);
    for (const scenario of [
      { label: "no goal dossier", functions: [start], goal: "0x4000", expected: 1 },
      { label: "goal has outgoing edge", functions: [start, goal], goal: "0x4000", expected: 1 },
      { label: "zero hops", functions: [start], goal: "0x1000", expected: 0 },
    ]) {
      const reply = await client.callTool({ name: "build_call_path", arguments: {
        functions: scenario.functions,
        start: { address: "0x1000" }, goal: { address: scenario.goal },
      } });
      expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
      const result = callPathResultSchema.parse(
        parseEvidence(reply.structuredContent).normalized_result,
      );
      console.log(JSON.stringify({ scenario: scenario.label, status: result.status,
        shortest_hops: result.shortest_hops, search_scope: result.search_scope,
        explored: result.explored, limitations: result.limitations }));
      expect(result.status).toBe("found");
      expect(result.search_scope.exhaustive).toBe(true);
      expect(result.limitations).toEqual([]);
      expect.soft(result.explored.edges, scenario.label).toBe(scenario.expected);
    }
  } finally {
    await Promise.allSettled([session.close(), client.close(), server.close()]);
  }
});

The final run completed all three cases. The no-goal-dossier control passed. The other two cases failed the explored-edge assertions with received values 2 and 1, respectively.

Expected behavior

Count edges expanded by the forward search: one for the single-hop case and zero for the zero-hop case. Supplying outgoing relationships for a terminal goal should not change this statistic.

The search explicitly treats the goal as terminal. Existing tests require that its callees do not extend the searched closure: goal with outgoing relationships and unknown goal coverage. The expectation concerns forward search extent, not the total supplied graph or just the returned shortest path's edges.

Impact and scope

Incorrect machine-readable search-extent metadata can overstate how many edges were explored. The source implies an overcount equal to the reached goal's out-degree; the executed examples demonstrate an excess of one. Paths, shortest-hop counts, exhaustiveness, and limitations behaved correctly in these cases. No downstream decision failure was tested.

A manual workaround is to count outgoing edges for reached, expanded functions while excluding the terminal goal. This issue does not claim a native provider coverage failure.

REA version

Source commit 52415975942a4b9c53a817915b313ccc404f280e (package metadata: rea-agents 6.7.0).

Runtime and platform

macOS 27.0.1 arm64, Node v26.12.0, locked Vitest 5.0.3. Synthetic Evidence; no Hopper, Ghidra, or IDA engine used.

Target and artifact context

Synthetic compatible function dossiers derived from the repository's FUNCTION_COMPARISON_EXAMPLE fixture. All Evidence records use the same artifact identity and provider and complete relationship coverage.

Interface

MCP

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed summaries from the successful MCP responses:

{"scenario":"no goal dossier","status":"found","shortest_hops":1,"search_scope":{"exhaustive":true},"explored":{"nodes":2,"edges":1,"depth_reached":1},"limitations":[]}
{"scenario":"goal has outgoing edge","status":"found","shortest_hops":1,"search_scope":{"exhaustive":true},"explored":{"nodes":2,"edges":2,"depth_reached":1},"limitations":[]}
{"scenario":"zero hops","status":"found","shortest_hops":0,"search_scope":{"exhaustive":true},"explored":{"nodes":1,"edges":1,"depth_reached":0},"limitations":[]}

The focused test failed with goal has outgoing edge: expected 2 to be 1 and zero hops: expected 1 to be +0. These are result assertions, after successful schema admission and MCP execution.

Typed outcome or recovery expected

Preserve the existing found results and citations while returning explored.edges: 1 and 0 for the respective affected cases. No new error type is needed.

Analysis or suspected cause

Source-derived explanation: summarizeSearch sums every reached node's out-degree. The reached map contains the terminal goal, but forward expansion stops before its outgoing neighbors. Counting at the expansion boundary, or otherwise distinguishing expanded nodes from the terminal goal, would align the statistic with that search.

The registered MCP handler publishes this domain result in derived Evidence.

Additional context

The relevant domain, schema, and MCP handler files are unchanged between the reported baseline and origin/main at c324d31b45fcab77e4ed7320ed60f0bf5a4ba188, checked by fetching and comparing source. The current head was not executed separately.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5906 Originally assigned to: @kaoru0822-kitauji on GitHub. ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `build_call_path` includes the terminal goal's outgoing relationships in `explored.edges`, although its forward search does not expand that goal. Reproduced through public MCP using synthetic, valid `analyze_function` Evidence and the repository's in-memory session harness. With `0x1000 → 0x4000`, the result reports one explored edge. Adding a dossier for `0x4000 → 0x5000` changes that count to two while explored nodes and depth remain two and one. A zero-hop search from `0x1000` to itself reports one explored edge at depth zero when its dossier has one callee. ### Reproduction At the revision below, save this test as `tests/boundary/mcp/callPathGoalEdges.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/mcp/callPathGoalEdges.test.ts --maxWorkers 1 ``` This is the complete test executed, with only the filename changed. It creates authenticated fixture Evidence, records it in a session, and calls the registered MCP tool. No native analysis provider or binary execution is involved. ```ts import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { callPathResultSchema } from "../../../src/domain/callPathSchemas.js"; import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; const observe = (address: string, callees: string[]) => { const base = FUNCTION_COMPARISON_EXAMPLE.left; if (base.subject === null) throw new Error("Missing fixture subject"); const dossier = functionDossierSchema.parse(base.normalized_result); return createEvidence( { path: base.subject.local_path, sha256: base.subject.digest.sha256, format: base.subject.format }, base.provider, { operation: "analyze_function", parameters: { procedure: address }, result: { ...dossier, procedure: { ...dossier.procedure, address }, callees: callees.map((callee) => ({ address: callee, name: `fn_${callee}`, classification: null, body: dossier.procedure.body })), callers: [], unresolved_calls: [], limitations: [] }, confidence: base.confidence, authority: base.authority, relationshipCoverage: { status: "complete" } }, ); }; it("counts only edges expanded before the terminal goal", async () => { const session = createTestBinarySession(() => { throw new Error("Offline reconstruction must not launch a provider"); }); const server = createServer({ kind: "session", session }); const client = new Client({ name: "call-path-edge-reproduction", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const start = observe("0x1000", ["0x4000"]); const goal = observe("0x4000", ["0x5000"]); for (const evidence of [start, goal]) expect(session.recordEvidence(evidence).ok).toBe(true); for (const scenario of [ { label: "no goal dossier", functions: [start], goal: "0x4000", expected: 1 }, { label: "goal has outgoing edge", functions: [start, goal], goal: "0x4000", expected: 1 }, { label: "zero hops", functions: [start], goal: "0x1000", expected: 0 }, ]) { const reply = await client.callTool({ name: "build_call_path", arguments: { functions: scenario.functions, start: { address: "0x1000" }, goal: { address: scenario.goal }, } }); expect(reply.isError, JSON.stringify(reply)).not.toBe(true); const result = callPathResultSchema.parse( parseEvidence(reply.structuredContent).normalized_result, ); console.log(JSON.stringify({ scenario: scenario.label, status: result.status, shortest_hops: result.shortest_hops, search_scope: result.search_scope, explored: result.explored, limitations: result.limitations })); expect(result.status).toBe("found"); expect(result.search_scope.exhaustive).toBe(true); expect(result.limitations).toEqual([]); expect.soft(result.explored.edges, scenario.label).toBe(scenario.expected); } } finally { await Promise.allSettled([session.close(), client.close(), server.close()]); } }); ``` The final run completed all three cases. The no-goal-dossier control passed. The other two cases failed the explored-edge assertions with received values `2` and `1`, respectively. ### Expected behavior Count edges expanded by the forward search: one for the single-hop case and zero for the zero-hop case. Supplying outgoing relationships for a terminal goal should not change this statistic. The [search explicitly treats the goal as terminal](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/callPath.ts#L250-L253). Existing tests require that its callees do not extend the searched closure: [goal with outgoing relationships](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/callPath.test.ts#L257-L276) and [unknown goal coverage](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/callPath.test.ts#L426-L442). The expectation concerns forward search extent, not the total supplied graph or just the returned shortest path's edges. ### Impact and scope Incorrect machine-readable search-extent metadata can overstate how many edges were explored. The source implies an overcount equal to the reached goal's out-degree; the executed examples demonstrate an excess of one. Paths, shortest-hop counts, exhaustiveness, and limitations behaved correctly in these cases. No downstream decision failure was tested. A manual workaround is to count outgoing edges for reached, expanded functions while excluding the terminal goal. This issue does not claim a native provider coverage failure. ### REA version Source commit `52415975942a4b9c53a817915b313ccc404f280e` (package metadata: `rea-agents` 6.7.0). ### Runtime and platform macOS 27.0.1 arm64, Node v26.12.0, locked Vitest 5.0.3. Synthetic Evidence; no Hopper, Ghidra, or IDA engine used. ### Target and artifact context Synthetic compatible function dossiers derived from the repository's `FUNCTION_COMPARISON_EXAMPLE` fixture. All Evidence records use the same artifact identity and provider and complete relationship coverage. ### Interface MCP ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed summaries from the successful MCP responses: ```json {"scenario":"no goal dossier","status":"found","shortest_hops":1,"search_scope":{"exhaustive":true},"explored":{"nodes":2,"edges":1,"depth_reached":1},"limitations":[]} {"scenario":"goal has outgoing edge","status":"found","shortest_hops":1,"search_scope":{"exhaustive":true},"explored":{"nodes":2,"edges":2,"depth_reached":1},"limitations":[]} {"scenario":"zero hops","status":"found","shortest_hops":0,"search_scope":{"exhaustive":true},"explored":{"nodes":1,"edges":1,"depth_reached":0},"limitations":[]} ``` The focused test failed with `goal has outgoing edge: expected 2 to be 1` and `zero hops: expected 1 to be +0`. These are result assertions, after successful schema admission and MCP execution. ### Typed outcome or recovery expected Preserve the existing found results and citations while returning `explored.edges: 1` and `0` for the respective affected cases. No new error type is needed. ### Analysis or suspected cause Source-derived explanation: [summarizeSearch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/callPath.ts#L130-L143) sums every reached node's out-degree. The reached map contains the terminal goal, but [forward expansion stops before its outgoing neighbors](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/callPath.ts#L250-L287). Counting at the expansion boundary, or otherwise distinguishing expanded nodes from the terminal goal, would align the statistic with that search. The [registered MCP handler](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerInvestigationTools.ts#L136-L205) publishes this domain result in derived Evidence. ### Additional context The relevant domain, schema, and MCP handler files are unchanged between the reported baseline and `origin/main` at `c324d31b45fcab77e4ed7320ed60f0bf5a4ba188`, checked by fetching and comparing source. The current head was not executed separately.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3585
No description provided.