Skip to content

fix(query): skip covered-term seed guarantee; relax starving heuristic context filters (#2507) - #2516

Closed
filipechagas wants to merge 2 commits into
Graphify-Labs:v8from
lawnstarter:feat/seed-hygiene-2507
Closed

fix(query): skip covered-term seed guarantee; relax starving heuristic context filters (#2507)#2516
filipechagas wants to merge 2 commits into
Graphify-Labs:v8from
lawnstarter:feat/seed-hygiene-2507

Conversation

@filipechagas

Copy link
Copy Markdown

Addresses the two failure modes of #2507 that remain after 0.9.35's relational-verb demotion. One commit on top of 9f25a3a; your demotion (_RELATIONAL_INTENT_TERMS, the inline terms-keyed fallback, the caller/callers hint entry, and the four #2507 tests) is byte-identical — this layers on it, it does not relitigate it.

The interaction worth stating plainly

0.9.35 added caller/callers to the call entry of _CONTEXT_HINTS, so "callers of X" now infers a call context filter. A class node owns no call edges — calls attach to its methods, and the class→member edge carries context=None — so the filtered traversal cannot leave a class-node seed. Measured on a 48.5k-node Laravel corpus where the queried service has exactly 3 grep-verified production callers: "callers of ChargeCustomerService" went from 147 nodes / all 3 callers present-but-buried (0.9.34) to 1 node / 0 of 3, delivered confidently (0.9.35). The existing _make_callers_graph fixture can't observe this because it hangs context="call" edges directly off the class node. The hint entry itself is good and is untouched here — the class-node stranding underneath it is what this PR repairs.

The two changes (both default-inert, opted into only by _query_graph_text)

  1. Covered-term guarantee skip — a new keyword-only, default-off _pick_seeds parameter. In the guarantee loop, a term that is a substring of an already-picked seed's normalized label (the scorer's own weakest match tier, judged on the label alone — a labelless alias-only node covers nothing) claims no additional seed: it is not starved, and starvation is the only thing the guarantee exists to prevent. Refined invariant: every term with any match is matched by at least one seed. Runs after the existing label-dedup gate, same sorted-term order. Without it, "what code uses ChargeCustomerService to charge a customer" seeds the Customer hub and .charge() on top of ChargeCustomerService and explodes: measured 2,286 → 138 nodes.
  2. Heuristic-filter starvation fallback — after traversal, when the active filters came from the heuristic (never from explicit arguments) and the traversal discovered no nodes beyond the seeds, retraverse unfiltered and annotate the header: Context: call (heuristic; relaxed — no matches beyond seeds). Zero-expansion threshold (no tuning constant, and pinned by a mutation-checked boundary test: exactly one node of expansion is NOT relaxed), identical in BFS/DFS, explicit filters always honored.

Measured, 9f25a3a → this branch (same corpus, CLI defaults: depth 2, 2,000-token budget)

Phrasing Nodes Callers shown Header
"Who calls ChargeCustomerService?" 1 → 128 0/3 → 3/3 call (heuristic; relaxed — no matches beyond seeds)
"callers of ChargeCustomerService" 1 → 128 0/3 → 3/3 same (was stranded at the seed)
"what code uses ChargeCustomerService to charge a customer" 2,286 → 138 0/3 → 3/3 none inferred

explain on a listener question is regression-free at an identical token cost — it shares none of the changed code (_find_node only).

Tests

  • tests/seeding_fixtures.py (new): a fixture wired the way extraction really wires things — calls attach to methods, the class→method edge carries context=None, one caller reachable only through a references edge, a verb-prefix decoy with a busy test-method neighborhood, a generic-noun hub, a doc-file prefix decoy. This is what makes the stranding observable in a test at all.
  • tests/test_serve_seeding.py (new): 14 tests, 7 red at 9f25a3a (both changes red-first), 7 green-by-design pins including regression pins for 0.9.35's demotion restated on extraction-shaped wiring. Both threshold/predicate mutants kill exactly their intended test.
  • Zero baseline edits: git diff --stat 9f25a3a..HEAD -- tests/ is two new files only; your four query: relational-intent verbs ("calls"/"uses") survive stopwording, prefix-match unrelated identifiers, and the #1445 per-term guarantee seats the junk match as a seed #2507 tests and the scoring benchmark are untouched and green. Full suite 4,006 → 4,020 passed (the 4 test_ollama_retry_cap.py failures are a local missing-openai env issue, present at base). Bench equality gate holds. ruff check clean.

Disclosures and questions for the maintainer

  • Vocabulary follow-ups, not changed here: usage (the only use-family member not demoted), the listen family (listeners of <Event> is the same question shape on an event-driven corpus), and invocation (already a _CONTEXT_FILTER_ALIASES key). Also a structural question: since 0.9.35 hardcodes the demotion list while _CONTEXT_HINTS grows independently (caller/callers was exactly such a growth), do you want the set derived from the hint/alias tables so they can't drift apart?
  • One test builds its pre-fix comparison baseline by importing _RELATIONAL_INTENT_TERMS (there is no named demotion helper to call). It asserts nothing about the set's contents, and the realistic vocabulary extensions above leave it green (measured); the known latent coupling is that demoting a test-query's own domain nouns (e.g. adding both "charge" and "customer" — contrary to the set's documented verbs-only rule) would collapse the comparison to equality. Happy to restructure if you'd rather extract the inline demotion into a helper.
  • Known residual, deliberately not chased: a generic term like "code" can still guarantee-seed a doc-file prefix decoy (coder.md, 10 of 138 traversed nodes above); your open work on test-path seed exclusion and bounded recovery would shrink it further.
  • The changelog entry carries no thanks @… line — it felt wrong to self-credit; add attribution as you see fit.

Prior art: the same two changes (plus an equivalent demotion) shipped on the LawnStarter fork as 0.9.36 (lawnstarter#37, PR lawnstarter#44) with matching graded numbers on the same corpus.

🤖 Generated with Claude Code

…c filters; bump to 0.9.36 (Graphify-Labs#2507)

Two follow-ups to 0.9.35's relational-verb demotion, on the same natural-language
impact question ("who calls X?") and graded on the same 48.5k-node PHP corpus.

- `_pick_seeds` gains a keyword-only, default-off `skip_covered_terms`: a term
  that is a substring of an already-picked seed's normalized label is not
  starved, so it claims no additional guaranteed seed. Layered after the Graphify-Labs#1766
  dedup gate, in the same sorted-term order. Coverage reads the seed's LABEL
  only, never the `or nid` dedup key, so a labelless (`_fold_node_aliases`)
  seed cannot declare unrelated terms covered through its path fragments.
  Opted into by `_query_graph_text` alone; `path`, `explain`, the
  legacy-equality property tests and the benchmark's arms are unchanged.

- `_query_graph_text` retraverses unfiltered when a *heuristically inferred*
  context filter discovers nothing beyond the seeds, and annotates the
  `Context:` header. A class node owns no call edges, so the `call` filter that
  "Who calls X?" infers strands a correctly-seeded class at exactly one node.
  Threshold is zero expansion (`nodes <= set(start_nodes)`), not a tuning
  constant; identical in BFS and DFS; explicit filters are never relaxed.
  0.9.35 added `caller`/`callers` to `_CONTEXT_HINTS`, so "callers of X" infers
  the same filter and hit the same stranding — measured on the corpus, both
  phrasings returned 1 node before this change and 128 after, with all three
  known callers in the shown portion.

New `tests/seeding_fixtures.py` carries a fixture wired the way extraction
really wires a service (calls land on the method; the class->method edge has no
context), which is what makes the stranding observable; `test_serve_seeding.py`
pins all three seeding behaviours, including regression pins for the landed
0.9.35 demotion.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.


Graphify review — findings

This PR bumps the version from 0.9.35 to 0.9.36 and modifies the query seeding logic in graphify/serve.py. It adds a skip_covered_terms option to _pick_seeds (opted into only by the natural-language query path) that suppresses the per-term seed guarantee when a term is already a substring of a picked seed's normalized label, and adds a fallback in _query_graph_text that retraverses unfiltered when a heuristically inferred context filter reaches nothing beyond the seeds, annotating the header accordingly. It also adds a new shared test fixture file (tests/seeding_fixtures.py) and corresponding test changes, plus changelog entries describing these two fixes (#2507).

Worth a look

  • Covered-term skip can starve a legitimately distinct term whose winner shares a substring with an unrelated seedgraphify/serve.py:758 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 649 functions depend on the 345 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _query_graph_text() — 29 callers, 6 callees
  • worse: _score_query() — 21 callers, 5 callees
  • worse: _query_terms() — 22 callers, 3 callees
  • new: make_charge_fixture() — 14 callers, 3 callees
  • new: test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded() — 0 callers, 11 callees
  • new: test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed() — 0 callers, 7 callees
  • new: test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed() — 0 callers, 7 callees
  • new: test_expanding_heuristic_filter_is_left_in_force() — 0 callers, 6 callees
  • …and 1 more

Verification — 649 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 509 function(s) in the blast radius were not formally verified this run

· 7 grounded finding(s) anchored inline below; 3 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/serve.py Outdated
Comment on lines +752 to +757
# Layered after that dedup gate, in this same sorted-term order, so
# the coverage check sees the gap-window seeds plus every guarantee
# seed appended so far.
if skip_covered_terms and any(term in _seed_norm_label(s) for s in seeds):
continue
seen_labels.add(key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered-term skip can starve a legitimately distinct term whose winner shares a substring with an unrelated seed — agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Graphify suggests a fix:

Suggested change
# Layered after that dedup gate, in this same sorted-term order, so
# the coverage check sees the gap-window seeds plus every guarantee
# seed appended so far.
if skip_covered_terms and any(term in _seed_norm_label(s) for s in seeds):
continue
seen_labels.add(key)
# Layered after that dedup gate, but only the TOP-ranked seed — the
# query's dominant match — can declare a term covered. A coincidental
# substring collision inside some unrelated, lower-ranked seed's label
# must not silently starve a distinct term's real winner.
if skip_covered_terms and seeds and term in _seed_norm_label(seeds[0]):
continue
seen_labels.add(key)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and adopted in 234c5f0 — thanks. The finding reproduces: with a node labelled exactly port (its term's winner by ~7,300x in _score_query) and an unrelated ReportService in the gap window, the old predicate let ReportService absorb "port" and the real winner was starved, while the legacy picker seeds it correctly. I've taken the suggested seeds[0] refinement (guarded on seeds being non-empty), which restates the invariant as every term with any match is matched by the top-ranked seed or by a seed of its own, and added the repro as a regression test along with the docstring, comment and CHANGELOG corrections. Measured behaviour on the graded corpus is unchanged — all three phrasings return byte-identical output to the previous commit, since the covering seed there was always the top seed.

Comment thread tests/seeding_fixtures.py
confidence="EXTRACTED", _src=src, _tgt=tgt)


def make_charge_fixture(*, calls_symbol: bool = False) -> nx.Graph:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionmake_charge_fixture()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended shape rather than a regression: the 14 callers are the point of this fixture — all three behaviours (verb demotion, covered-term skip, filter relaxation) grade the same corpus instead of each test re-inventing a graph, which is what makes the phrasing results comparable across the suite. The fan-in is confined to tests/ and no production code depends on it.

_COVERED_AND_STARVED_QUESTION = "ChargeCustomerService customer code"


def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()

fans out to 11 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callees here are the shared fixture plus the small output-parsing helpers (start_labels, shown_nodes, label_of, caller_labels) that keep every assertion reading rendered text instead of internals. That indirection is deliberate — it's what makes these tests robust to vocabulary tweaks — and it's test-local; no production coupling changes.

# *explicit* filter is always honored. #
# --------------------------------------------------------------------------- #

def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling threads: the fan-out is the shared fixture plus the header/output-parsing helpers that keep assertions on external behaviour (rendered Start: seeds, NODE lines, the Context: note) rather than internals. Test-local by design; no production coupling changes. No action taken — happy to restructure if the metrics gate treats this as blocking.

assert "relaxed" in context, f"header does not report the relaxation: {context!r}"


def test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_callers_of_phrasing_renders_all_callers_and_drops_junk_seed()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling threads: the fan-out is the shared fixture plus the header/output-parsing helpers that keep assertions on external behaviour (rendered Start: seeds, NODE lines, the Context: note) rather than internals. Test-local by design; no production coupling changes. No action taken — happy to restructure if the metrics gate treats this as blocking.

)


def test_expanding_heuristic_filter_is_left_in_force():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_expanding_heuristic_filter_is_left_in_force()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling threads: the fan-out is the shared fixture plus the header/output-parsing helpers that keep assertions on external behaviour (rendered Start: seeds, NODE lines, the Context: note) rather than internals. Test-local by design; no production coupling changes. No action taken — happy to restructure if the metrics gate treats this as blocking.

)


def test_single_node_expansion_is_not_starvation():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_single_node_expansion_is_not_starvation()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the sibling threads: the fan-out is the shared fixture plus the header/output-parsing helpers that keep assertions on external behaviour (rendered Start: seeds, NODE lines, the Context: note) rather than internals. Test-local by design; no production coupling changes. No action taken — happy to restructure if the metrics gate treats this as blocking.

…hify-Labs#2507)

Review follow-up. The covered-term skip let ANY picked seed absorb a term by
substring, so a coincidental collision inside an unrelated, lower-ranked seed's
label starved the term's real winner: `ReportService` contains "port", which
cost a corpus symbol literally named `port` the guaranteed seat that was its
only way into the seed list — Graphify-Labs#1597's concern one layer down.

Coverage asserts "the query's dominant match already answers this term", which
is a claim only the top-ranked seed is entitled to make, so the predicate now
reads `seeds[0]` alone (guarded on `seeds` being non-empty). `_seed_norm_label`
keeps its label-only semantics, so a labelless seed still covers nothing. The
refined invariant: every term with any match is matched by the top-ranked seed
or by a seed of its own.

Behaviour on the graded corpus is unchanged — all three phrasings return output
byte-identical to the previous commit's, because the covering seed there is
always the top seed.
@filipechagas

Copy link
Copy Markdown
Author

Review round addressed in 234c5f0: the covered-term advisory was verified with a reproducing execution (details in the thread) and the suggested top-ranked-seed refinement adopted, with the repro added as a regression test and the docstring/comment/CHANGELOG wording corrected. Both mutation probes still kill their intended tests, the full suite is green (4,021 passed; the 4 test_ollama_retry_cap.py failures are a local missing-openai env issue present at base), and all three graded phrasings return byte-identical output to the previous commit — on the measured corpus the covering seed was always the top seed. The coupling-metric threads on the test files are answered inline: the fan-in/fan-out is the shared fixture + output-parsing helpers, test-local by design.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.


Graphify review — findings

This PR bumps the package version from 0.9.35 to 0.9.36 and adds two related changes to the natural-language query seeding pipeline in graphify/serve.py. It introduces a skip_covered_terms option to _pick_seeds (opted into only by _query_graph_text) that skips the per-term seed guarantee when a term is a substring of the top-ranked seed's normalized label, and adds a fallback in _query_graph_text that retraverses unfiltered when a heuristically inferred context filter discovers nothing beyond the seeds, annotating the header accordingly. The change also adds a new shared test fixture file (tests/seeding_fixtures.py) and associated tests, plus CHANGELOG entries describing both fixes. The surface area is concentrated in seed selection, context-filter handling, header formatting, and test scaffolding.

Worth a look

  • Covered-term skip uses substring test, matching arbitrary short termsgraphify/serve.py:771 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 651 functions depend on the 347 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _query_graph_text() — 29 callers, 6 callees
  • worse: _score_query() — 22 callers, 5 callees
  • worse: _query_terms() — 22 callers, 3 callees
  • new: make_charge_fixture() — 14 callers, 3 callees
  • new: test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded() — 0 callers, 11 callees
  • new: test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed() — 0 callers, 7 callees
  • new: test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed() — 0 callers, 7 callees
  • new: test_expanding_heuristic_filter_is_left_in_force() — 0 callers, 6 callees
  • …and 1 more

Verification — 651 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 511 function(s) in the blast radius were not formally verified this run

· 6 grounded finding(s) anchored inline below; 3 more finding(s) on lines outside this diff (see the check run).

Comment thread tests/seeding_fixtures.py
confidence="EXTRACTED", _src=src, _tgt=tgt)


def make_charge_fixture(*, calls_symbol: bool = False) -> nx.Graph:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionmake_charge_fixture()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

_COVERED_AND_STARVED_QUESTION = "ChargeCustomerService customer code"


def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_generic_noun_phrasing_seeds_no_hub_and_stays_bounded()

fans out to 11 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# *explicit* filter is always honored. #
# --------------------------------------------------------------------------- #

def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert "relaxed" in context, f"header does not report the relaxation: {context!r}"


def test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_callers_of_phrasing_renders_all_callers_and_drops_junk_seed()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

)


def test_expanding_heuristic_filter_is_left_in_force():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_expanding_heuristic_filter_is_left_in_force()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

)


def test_single_node_expansion_is_not_starvation():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_single_node_expansion_is_not_starvation()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@filipechagas

Copy link
Copy Markdown
Author

Status note, since #2507 is now closed as fixed and this PR is the part of it that remains open.

0.9.35 fixed direction 2 of the report — the relational-verb demotion — and this branch takes that commit byte-identical and does not relitigate it. The two failure modes from the original report that survive it are what is here:

  1. The per-term guarantee still seats a hub seed for a generic noun. "what code uses ChargeCustomerService to charge a customer" seeds the Customer hub and .charge() on top of the queried service and explodes to 2,286 nodes; with the covered-term skip, 138.
  2. A heuristic context filter can strand the traversal at its seed with no signal to the caller.

Worth separating out, because it is a regression rather than a leftover: 0.9.35 also added caller/callers to the call entry of _CONTEXT_HINTS. The hint entry itself is right, and this PR leaves it untouched — but a class node owns no call edges (they attach to its methods, and the class→member edge carries context=None), so the inferred filter cannot leave a class-node seed. Measured on a 48.5k-node Laravel corpus where the queried service has exactly 3 grep-verified production callers:

nodes callers shown
0.9.34 147 3/3, present but buried
0.9.35 1 0/3, delivered confidently
this branch 128 3/3, header marked call (heuristic; relaxed — no matches beyond seeds)

Confidently empty is the part that concerns us more than the burying did. The existing _make_callers_graph fixture cannot observe it, because it hangs context="call" edges directly off the class node rather than off its methods — which is why the new fixture in this PR wires calls the way extraction actually wires them.

Branch status: rebased on 9f25a3a, automated review round addressed in 234c5f0, full suite green, zero baseline test edits (git diff --stat 9f25a3a..HEAD -- tests/ is two new files).

No urgency from our side — we run these on our fork. Happy to split the two changes into separate PRs, or to take the structural question from the description (deriving the demotion vocabulary from _CONTEXT_HINTS / _CONTEXT_FILTER_ALIASES so the two cannot drift as _CONTEXT_HINTS grows — caller/callers was exactly such a growth) as a separate change afterwards. Whichever is easier to review.

@filipechagas

Copy link
Copy Markdown
Author

One more consolidated note, on the automated coupling threads (there are six distinct ones, each posted twice). I answered these inline in the earlier round; collecting the argument in one place since they all reduce to the same thing.

Every flag on this PR targets test code, and all of it is in the two new files this PR adds:

thread metric
tests/seeding_fixtures.py:114 make_charge_fixture() 14 callers (afferent)
tests/test_serve_seeding.py:168 test_generic_noun_phrasing_... 11 callees (efferent)
tests/test_serve_seeding.py:349 test_who_calls_phrasing_... 7
tests/test_serve_seeding.py:373 test_callers_of_phrasing_... 7
tests/test_serve_seeding.py:400 test_expanding_heuristic_filter_... 6
tests/test_serve_seeding.py:472 test_single_node_expansion_... 6

These counts are the fixture pattern working, not coupling debt. tests/seeding_fixtures.py is new in this PR, so there is no prior fan-in to regress from — and the 14 callers are the design: every test grades the same corpus so results stay comparable across phrasings, which is precisely what per-test hand-rolled graphs would destroy. That comparability is load-bearing here rather than stylistic: the whole reason this PR needed a new fixture is that the existing _make_callers_graph hangs context="call" edges directly off the class node, so it cannot observe the class-node stranding at all. A fixture that wires calls the way extraction really wires them is the thing that makes the bug visible in a test.

The efferent counts on the individual tests are that same shared fixture plus the small output-parsing helpers (start_labels, shown_nodes, label_of, caller_labels, _context_segment) that keep assertions on rendered text rather than on internals — which is what makes them robust to the vocabulary changes this area keeps getting.

No production symbol's coupling changed on this PR. Happy to restructure if the metrics gate treats this as blocking, but I'd be trading a deliberate design for a number.

filipechagas added a commit to lawnstarter/graphify that referenced this pull request Aug 9, 2026
Brings in the six upstream commits since the last sync point 9f25a3a:

- 6ba0868 fix(cli): surface four silent success-exit failures (Graphify-Labs#2534, Graphify-Labs#2522)
- 22c41c1 fix(swift): cross-file extension no longer drops static/singleton
  calls (Graphify-Labs#2538)
- 3c17238 fix(dedup): deterministic node-id collision rank, active over
  archived (Graphify-Labs#2532)
- 3d19463 fix(skill): make the Windows skill variant runnable on PowerShell;
  bump to 0.9.36 (Graphify-Labs#2528)
- cfc6a75 fix(extract): TS member-call gating + Kotlin grammar match (Graphify-Labs#2553,
  Graphify-Labs#2552, Graphify-Labs#2526, Graphify-Labs#2550, Graphify-Labs#2551)
- 09a34ad fix(update,llm): retry failed extractions; surface claude-cli
  envelope errors; bump to 0.9.37 (Graphify-Labs#2543, Graphify-Labs#2554)

MERGE, not rebase, per the fork's established convention (be80845, 725d082).
The fork is 64 commits ahead of the merge base and seven of its branches are
open PRs against upstream (Graphify-Labs#2536, Graphify-Labs#2516, Graphify-Labs#2506, Graphify-Labs#2505, Graphify-Labs#2503,
Graphify-Labs#2502, Graphify-Labs#2492); rebasing v8 would rewrite the history all seven are cut from.
None of the six commits above is one of those PRs, so nothing merged here
duplicates work still in review upstream.

Conflicts resolved (4 files):

* graphify/extractors/engine.py — two hunks, both pure additions at a shared
  insertion point, so BOTH SIDES ARE KEPT. The fork's PHP raw-call locals and
  markers (`php_function_call`, `fcc`, `receiver_type`,
  `receiver_type_qualified`, `receiver_qualified`, `php_inline_new_*`) and
  upstream's Kotlin `kotlin_qualified_prefix` / `qualified_prefix` stamp now
  sit side by side on the same `rc_entry`. The two write disjoint keys and
  neither reads the other's, so no ordering question arises.

* graphify/extract.py — one hunk, again additions at a shared point. The fork's
  `_is_php_function_target` helper (#52) and upstream's early
  `_KOTLIN_IMPORT_TARGET_RESOLVER` run are both kept, with the Kotlin run left
  immediately above the import-evidence index — upstream's comment states it
  must precede that index or the INFERRED -> EXTRACTED promotion reads
  pre-rewrite targets, and the fork's helper is a def with no ordering
  constraint, so it goes first.

* pyproject.toml — 0.9.40, with the matching one-line uv.lock bump (fork
  precedent 457b31e, be80845). A new number rather than either parent's:
  the fork's line is at 0.9.39 and upstream's at 0.9.37, and the fork has
  already numbered a 0.9.36 and a 0.9.37 of its own.

* CHANGELOG.md — a pure insertion collision, no competing prose. Upstream's
  0.9.37 (unreleased) and 0.9.36 sections are absorbed VERBATIM and placed
  below the fork's own sections, above upstream's dated 0.9.35. Both are
  retitled `(upstream, ...)` with a collision note, following the precedent the
  fork-only 0.9.35 section set — the fork's 0.9.36/0.9.37 already occupy those
  numbers, and renumbering either line would cascade. Upstream's 0.9.36 is
  stamped 2026-08-07 from its own released section; upstream's 0.9.37 is still
  unreleased on their side (tag v0.9.37 is at 09a34ad, `git tag --contains`).
  A new `## 0.9.40 (unreleased)` on top describes this sync.

Semantic overlap, the one place a clean text merge was not enough:
upstream's Graphify-Labs#2553 rewrote `_resolve_typescript_member_calls`, which is also the
function the fork's cross-language isolation work rewrote (#24 index scoping,
#10 raw-call ownership). Git auto-merged it, and the result is correct because
BOTH SIDES ARE REFUSALS and therefore compose: the fork's
`_is_owned_definition` / `_raw_call_is_owned` gates keep the resolver off
definitions written in another language, and upstream's origin gate
additionally requires the matched type to be visible to the calling file (same
file, a named import, or a module the file imports). Upstream's other two
changes to that function are adopted unchanged — the `references`-edge fallback
for a typed receiver whose type lacks the method is dropped, and a
table-typed receiver is tiered INFERRED 0.8 while a source-written
`Type.method()` stays EXTRACTED 1.0.

One fork test adapted, and only because that adopted gate changed its path:
tests/test_mixed_corpus_member_calls.py::
test_typescript_receiver_resolves_despite_a_same_named_python_class asserted
EXTRACTED on a fixture whose `runner.ts` named `Lead` without importing it —
invalid TypeScript that resolved only because the pre-gate resolver matched on
name alone. The fixture gains the `import { Lead } from './lead';` real
TypeScript requires, and now asserts INFERRED, matching the Swift sibling test
that has always asserted it. What the test is FOR is unchanged and still holds:
the same-named Python decoy must not suppress the real TS edge (#24) — verified
directly through extract(), the TS edge resolves and `svc.py`'s `Lead.search`
gets nothing. The negative sibling
(test_typescript_receiver_type_does_not_match_a_python_class) deliberately
keeps the import-less `_TS_CALLER`, so it goes on pinning the fork's
definition-index scoping on its own evidence instead of passing for upstream's
reason. The `_call_context_pairs` docstring is corrected: the TS `references`
fallback it described no longer exists.

Verified byte-for-byte against both parents: all 51 upstream-only files are
blob-identical to upstream/v8 and 24 of 26 fork-only files blob-identical to
v8 — the two exceptions are exactly the deliberate edits above (the test file
and uv.lock's version line). For the four both-touched CODE files, every added
line from BOTH parents is present in the result, zero missing (cli.py fork
14 / upstream 193; extract.py 675 / 430; engine.py 899 / 146; watch.py 14 / 47).
cli.py and watch.py auto-merged with no overlapping hunks, and both sides'
features are live: the fork's `_php_class_fqns` / `_php_non_class_types`
resolution-context replay and upstream's `failed_sources` manifest handling.
build.py, dedup.py, detect.py, llm.py, tree_html.py, the skill artifacts and
tools/skillgen were untouched by the fork, so upstream's versions are taken
wholesale.

Suite: 4404 passed, 37 skipped, 0 failed. Pre-merge baseline on v8 was
4343 passed, 36 skipped, 0 failed; upstream adds 61 tests and 1 skip, so the
delta is fully accounted for and there are no pre-existing failures to discount.
`ruff check graphify/ tests/ tools/` clean; `python -m tools.skillgen --check`
reports 134 artifacts matching committed output.

NOT verified in this commit, and deliberately deferred to the reviewer: no
real-corpus spot-check was run. Four of the six upstream commits are
extraction-side (Swift extensions, Kotlin, the JS/TS callback walk, the TS
origin gate), so a Swift/Kotlin/JS/TS corpus needs `graphify update .` to pick
them up; the fork's own PHP corpus numbers are unaffected by anything here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@filipechagas

Copy link
Copy Markdown
Author

We close this PR in favor of one consolidated PR.

The evidence in this PR's description stays valid and is referenced from #2617.

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