Skip to content

Agent-asserted call edges: list the sites an answer stops at, link them, walk them as [asserted] (#1892) - #1895

Merged
swapnilpaliwal-sd merged 11 commits into
apps/integration-0.1.9from
feat/asserted-edges
Oct 10, 2026
Merged

swapnilpaliwal-sd merged 11 commits into
apps/integration-0.1.9from
feat/asserted-edges

Conversation

@swapnilpaliwal-sd

Copy link
Copy Markdown
Contributor

Closes #1892 (draft).

Symptom

An answer that reaches a call the graph could not resolve (a callable read with getattr or a computed key, a handler kept in a table, a reflective invoke, a delegate or dynamic value) says only "N unresolved call(s) inside — a lower bound". The agent can often read the code and see where the call lands, but nothing lets it tell the graph, so every later impact / path / tests answer stops at the same site.

Mechanism

  • The gaps as a work list. impact and path list the sites they stopped at (--json: unknown_sites, up to 30; text: --unknown; the front door and MCP: a short to resolve: block). Each site is file:line:col (the column of the callee's name), the call as written, the engine's own reason, and up to five ranked candidate targets from what the graph already knows (the engine's own target set, values the callers pass into the called parameter, a computed name's constant prefix, callables handed over as values in the same file, declarations of the name called). A library call that runs a value (Method.invoke, MethodInfo.Invoke, Function.apply) and a call through a function-typed holder are listed too.
  • axiomcode link <file:line[:col]> <target> (CLI and an MCP link tool) records the assertion in axiomcode-links.tsv at the repository root — committable on purpose: .axiomcode/ is derived and routinely deleted, a link is knowledge someone read the code to get. Each row carries the line's text hash, the column, the callee as written, the enclosing callable and the target as the graph names it. axiomcode link lists every link with its status; link <site> - removes; link <site> not:<target> (or --not) rejects a lead.
  • Validation, language-agnostic. A link applies only if a call is written at that site whose callee is consistent with the target (same name; the class of a constructor; or a call through a value — the callee names no callable, the call is computed/reflective, or the engine's reason says so; a member call on an untyped receiver only to its own name; a construction only to its type), the target is a declaration in the graph, and the line's text is unchanged. Two calls one name on a line are never chosen between: the column is required. A rejected or stale link is reported, never applied.
  • Applied as a call edge of tier asserted (certainty asserted, never resolved), at the end of every index (so a rebuild keeps them, O(links)), on link itself against the existing graph, and on the next query after the links file changes. Links only add edges; nothing the engine wrote is removed.
  • What the link makes resolvable after it. When the target's return type is known (declared, a JSDoc @returns, or every return constructing one type / returning this), calls chained on the result, on a local assigned from it (once), awaited, or on each element of a returned collection resolve to that type's members, also as asserted, and drop with the link.
  • Rejections. A by-name or one-of-a-set lead can be rejected per site; the walks skip it (impact's by-name rules and calls facts, path's edges and by-name closure), the engine row stays. An edge the engine resolved is refused. AXIOMCODE_LINKS_PREFER=1 additionally skips a linked site's own guesses.
  • Following edits. A link follows its line by text when lines above it move, but only within the callable it was made in (an identical line in another function is not it), re-maps its column through re-spacing, and drops when the line, the target (renamed / deleted / moved) or the file goes.
  • Cost. link patches the derived facts the walks read (path edges, impact calls / certainty table) and moves their stamps, instead of re-exporting.

Numbers

Stress (scenarios per language on one real repository each plus the synthetic cases; link N sites, edits above/on the line, target renamed/moved, file deleted, query during a rebuild, fans and duplicates, wrong links, checkout and back, a corrupt links file, two calls of one name on a line, typing what follows a link, rejections): every scenario passes in every language where the repository can exhibit it; the misses are data limits (a subject with 4 unknown sites cannot take 20 links).

Latency on the largest Python subject (11.7k call edges): link 0.08 s; the next impact 0.85 s with the patched facts vs 2.31 s re-exporting; the next path 0.25 s. A rebuild applies 200 links in 48–161 ms.

Simulated perfect asserter on the 16-repo Python corpus (a runtime trace says which unknown site calls which project function; links only those, validated by the same rules): 2,828 links.

all 16 repos base add-only add + prefer + reject
reach recall (src) 0.740 0.789 0.789 0.781
reach precision (ran) 0.675 0.650 0.653 0.711
reach F1 0.706 0.713 0.715 0.744
tests recall 0.851 0.872 0.872 0.868
tests precision 0.417 0.396 0.396 0.403
path found 0.727 0.792 0.792 0.785
path test→target found 0.559 0.685 0.685 0.682
callers recall / precision 0.828 / 0.921 0.889 / 0.924 0.889 / 0.924 0.889 / 0.954

Held-out alone: path found 0.628 → 0.745, reach recall 0.517 → 0.569. Of the runtime edges the graph misses, 26% sit in a caller with no unknown site (no link can reach them) and another 26% in a caller with several value calls the trace cannot tell apart. The graph's candidates named the true target for 45% of linked sites (top-1 25%). Rejections (22k, simulated) recover precision but cost a little recall: a rejected lead sometimes covered a true caller the graph misses elsewhere. Prefer mode moved almost nothing.

Validation

tests/cases/<lang>/asserted-links and asserted-links-derive in all five languages (fail on the base: the verb does not exist), with controls: a target that does not exist, a line without a call, a call naming another declaration, a member call on an untyped receiver, the linked line edited, an unlinked sibling site, a resolved edge that cannot be rejected. tests/run.py gained "edit" (a file edited before a check, restored after the case) and "env". Full suite, tests/front_door.py, tests/surfaces.py, tests/mcp.py: see the comment below.

Not done

  • Typing what follows a link through generics substituted from the call, destructuring, pattern matching (instanceof T t, match/case, is T t), records' accessors, tuples: not typed at apply time (needs the engine's solve); a link still applies, nothing is derived there.
  • Rejecting by key, decorator by name, protocol and library callback leads: refused for now (only by-name and set members).
  • The SQL port of impact (AXIOMCODE_SQL) does not read rejections.

swapnilpaliwal-sd and others added 8 commits October 9, 2026 23:42
Measured against runtime traces on the Python oracle corpus, every false
transitive-reach row has a static edge the runtime never made (the scorer
accepts any runtime caller at any depth, so context-insensitivity cannot
produce one). Two of the edge kinds are wrong by construction and are
fixed here; reach precision 0.675 -> 0.724, reach recall -0.1pt (src) /
-0.2pt (test-side), test-file recall -0.1pt, callers and path unchanged.

1. A decoration string naming a PARAMETER of the decorated declaration is
   no registration key (ax_registration.decoration_key_strings, shape 4),
   nor is a string inside another call in the decoration (shape 5).
   `@option("--params", "-p", "params") def main(params)`: the flags are
   what a caller writes to reach the command; the parameter name is written
   by every function that builds a dict with that key, and the by-key join
   made each of them a caller of the command. The flags stay keys.

2. The "decorator by name" hop lands on what the decorator RETURNS, not on
   the decorator's body. It used to run decorated -> decorator, making the
   decorated function (and everything reaching it: a route's tests) a
   caller of whatever the decorator calls. Calling the decorated name never
   runs the decorator; it runs what the decorator returned:
     - bare `@d`: d's return; written as a call `@d(...)`: the return of
       what d returned (decorated_call);
     - a wrapper (fn_returns, from the engine's method_returns_method): the
       decorated declaration now stands for that wrapper;
     - the function handed back unchanged (fn_returns_param, a registering
       decorator): no hop at all; the decoration itself runs at import,
       which the import walk already follows;
     - unknown return: the old hop stays (a route the walk cannot judge).

IMPACT_VERSION 70 (new facts fn_returns, fn_returns_param, decorated_call).
Cases: decorator-by-name-runs-its-wrapper, option-parameter-name-is-no-key
(each fails on the base, with a control that must not change);
decorator-the-project-declares now expects the route it really takes
(`at import`) instead of the by-name hop.

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
… its class

`cls: type[X] | None = None` and `Optional[Type[X]]` are the default-None
spelling of a class-object parameter: the body swaps None for a default class
and calls `cls(...)`. Only a top-level `type[...]` annotation was read, so the
call stayed `callee_is_parameter` and every constructor behind it lost that
caller (a decorator factory building its command class in a closure is the
common shape). param_class_object_ref now names the `type[...]` subscript an
annotation is or one of its union operands is, and both the concrete
(expr_type_class_object) and the bounded-TypeVar (param_class_object_bound)
rules read it. The existing top-level rules are unchanged.

Corpus: one-hop callers src 0.828 -> 0.829, path found 0.727 -> 0.729,
test->target path 0.559 -> 0.562; reach and tests unchanged; engine suite and
torture unchanged (agree 588 / missing 46 / extra 37); index time flat.
Case: optional-class-object-parameter (fails on the base; the bare
`type[Command]` control passes on both).

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
…m, walk them as [asserted] (#1892)

An answer that stops at a call the graph could not resolve now lists the site (file:line:col, the call
as written, the engine's reason). axiomcode link <file:line[:col]> <target> records where it lands in
axiomcode-links.tsv; the graph validates each link (a call there consistent with the target, the
target declared, the line text unchanged) and applies it as a call edge of tier asserted, never
resolved. Links only add edges, survive rebuilds, follow their line by its text within the callable,
and drop (reported) when the line is edited. A link whose target declares a return type also resolves
calls chained on the result and calls on a local assigned from it.

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
…ha load-time throw selects the whole run

impact
- imports_fact resolves a directory import (`require('..')`, `require('../')`, a folder with a
  package.json main/exports), the repository's own package name, a side-effect `import './x'` and a
  literal `import('./x')`; a package whose entry is a build output the checkout lacks names its
  src/ (or lib/) entry. imports_dynamic records the files that load a module by a computed name.
- loads_change asks whether the test loads the asked declaration's FILE, not the end of the
  shortest route (which can run through another test file's subclass or callback); it says nothing
  where the target file has no importer, the test file imports nothing here, or the test's import
  closure holds a computed-name loader.
- a JavaScript test whose route is in-process (sound, one of a set, dispatch, registered, defines,
  fixture) and whose file never loads the changed file is kept apart as `not_loading_tests` and
  named on a `not counted:` line, never selected: a plugin's prototype override is credited only to
  the tests that load the plugin, and a callback one test file hands to a shared function no longer
  pulls in every other caller's tests. Key joins, remote hops and spawns are unaffected.
- load_run (impact.dl): test-code declarations that reach the change through calls alone; a test
  credited through its module body or describe() block that is one of them carries `at_load`.

path
- from a JavaScript test file that never loads the target's file: "no route: X never loads Y".

test-impact
- a selected JavaScript file no runner collects is replaced by the collected test files that
  require it, or moved to not_collected when none does (unless a test file loads modules by a
  computed name).
- a mocha run whose specs reach the change at load time (`at_load`) lists every spec that run
  collects: mocha loads them in one process and a throw there fails all of them. jest/vitest are
  per-file and unchanged.
- a changed function-constructor (`function Foo(opts) {` header edit is a TYPE to `changed`) is asked
  again as the function when impact finds no type at that line.

Measured on 8 JavaScript subjects against cached mutation truth (no-library protocol), base ->
this: impact R 0.522 -> 0.542, P 0.238 -> 0.295; tests R 0.494 -> 0.559, P 0.264 -> 0.310; held-out
impact P 0.156 -> 0.478 at equal recall; no failing (test file, target) pair lost in impact, tests
or path on any subject.

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
IMPACT_VERSION 70 carries both 69s (the library-callback receiver sites and the JavaScript import facts).

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
…'s result (#1892)

A site is file:line:col (the callee's name); two calls of one name on a line are never chosen between.
link --not rejects a by-name or one-of-a-set lead at a site (never an edge the engine resolved); the
walks skip it, nothing is deleted. AXIOMCODE_LINKS_PREFER=1 skips a linked site's own guesses.
Unknown sites carry ranked candidates from what the graph knows (the engine's set, values passed in,
a computed name's prefix, values registered in the file, the name called). A link whose target has a
declared, JSDoc or inferred return type resolves the calls chained on its result, on a local assigned
from it, awaited, or on each element it yields. Front-door answers list confirmed places before leads.

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
…cts a lead; candidates ranked by the name called (#1892)

A decorator factory's call and the decoration applying its result are one call at one column. Stress
harness scenarios S1-S13 pass in all five languages where the subject can exhibit them.

Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
@swapnilpaliwal-sd

Copy link
Copy Markdown
Contributor Author

Validation on the pushed head:

  • tests/run.py, all five languages: 1718 of 1718 checks in 331 cases (10 new cases, 123 checks).
  • tests/front_door.py: 21 of 21. tests/mcp.py: ok.
  • tests/surfaces.py: one failure, pre-existing on the base (the README CLI section names the flag --fresh); this branch adds nothing it flags.
  • No engine rule or front end changed: the links are applied where graph.sqlite is indexed and read by the query layer, so the engine suites are unaffected.

The README row for the new verb is not in this PR: the README already contains a term the repository's scrub hook blocks, so any commit touching it is refused until that is cleaned separately.

@swapnilpaliwal-sd
swapnilpaliwal-sd marked this pull request as ready for review October 10, 2026 17:40
…o fix/js-impact-f1

# Conflicts:
#	plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
# Conflicts:
#	plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
@swapnilpaliwal-sd

Copy link
Copy Markdown
Contributor Author

Tracked in #1897 (0.1.9 batch); merge in the order listed there.

@swapnilpaliwal-sd
swapnilpaliwal-sd merged commit 67e996a into apps/integration-0.1.9 Oct 10, 2026
12 checks passed
@swapnilpaliwal-sd
swapnilpaliwal-sd deleted the feat/asserted-edges branch October 10, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant