From 7ad2b0dafaac3a222100c5db2a3ea7f4c8b6fdb0 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 6 Jul 2026 17:05:05 +0100 Subject: [PATCH 1/3] docs(readme): reorder badges for click-through; split into top strip + footer Lead with what keeps a first-time visitor moving toward install/community instead of a terminal exit. Top strip trimmed 9 -> 5: PyPI, Downloads, CI, Discord, YC S26 (product -> adoption -> health, then the one converting click, with YC last: its credibility value is in being seen, not intercepting the front-door slot). Moved Sponsor, X and the book to a new "Community and links" footer (higher-intent readers who reach the bottom), added a contextual book link under "Learn more", and dropped the LinkedIn badge (near-zero value to a repo visitor; the org profile/site already carry it). Trendshift unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1952acbc2..40a488b97 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,11 @@

- YC S26 - Discord - The Memory Layer - CI PyPI Downloads - Sponsor - LinkedIn - X + CI + Discord + YC S26

Type `/graphify` in your AI coding assistant and it maps your entire project (code, docs, PDFs, images, videos) into a **knowledge graph** you can **query instead of grepping** through files. @@ -757,6 +753,7 @@ graphify label ./my-project --backend=openai --model gpt-4o # force a specific - [How it works](docs/how-it-works.md) — the extraction pipeline, community detection, confidence scoring, benchmarks - [ARCHITECTURE.md](ARCHITECTURE.md) — module breakdown, how to add a language - [Optional integrations](docs/docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite +- [The Memory Layer](https://safishamsi.gumroad.com/l/qetvlo) — the book on the ideas behind graphify, the architecture end to end --- @@ -828,3 +825,14 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to ad Star History Chart

+ +--- + +## Community and links + +

+ Discord + X + Sponsor + The Memory Layer +

From 8a608d1a99c8c79a249e61306ff092b9d99ec748 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 6 Jul 2026 17:07:03 +0100 Subject: [PATCH 2/3] docs(readme): swap CI badge for LinkedIn in the top strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer preference: drop the CI-passing badge from the hero strip and keep LinkedIn visible up top instead. Top strip stays 5: PyPI, Downloads, Discord, LinkedIn, YC S26 — trust signals lead, Discord is the converting click, and the two company/social links group at the tail. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 40a488b97..26143a143 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@

PyPI Downloads - CI Discord + LinkedIn YC S26

From 4214d7f4fc7d2b84647f1d649074abcbe793858f Mon Sep 17 00:00:00 2001 From: Farouk Khawaja Date: Mon, 6 Jul 2026 12:15:43 -0400 Subject: [PATCH 3/3] fix: scale per-term exact/prefix score tiers by squared label-term coverage (fixes #1602) In a multi-term query, a single generic term that exactly equals a short leaf label (query term "home" vs a home() controller action, "list" vs a list() function) receives _EXACT_MATCH_BONUS x IDF and outscores every node that matches several of the query's terms by roughly 10x. _pick_seeds' 20% gap cutoff then discards the genuinely relevant candidates, so BFS explores the neighborhood of one unrelated leaf. #1596 mitigated the downstream symptom by guaranteeing one seed per distinct term; this fixes the root ranking: nothing in _score_nodes rewarded matching more of the query. _score_nodes now scales the per-term exact/prefix tier contributions by squared term coverage: tiered * (matched_terms / total_terms) ** 2. - Squared, not linear, because the exact tier is 10x the prefix tier; at linear coverage a 1-of-10-terms exact hit still edges out a 3-of-10-terms prefix+substring match. The regression test pins this ratio coupling: retuning either tier constant re-fails it. - Coverage counts label hits (exact/prefix/substring) only. Source-path hits still score but do not count: a colliding leaf that lives in the target's own directory (the common case) must not win back its exact tier via path fragments. - Substring and source bonuses stay unscaled, as does the full-query tier (a full-label match already implies full coverage). - Query tokens are now deduped, order-preserving, as _pick_seeds already does: a repeated query word must not double-count every tier, and with coverage scaling it would also inflate the matched ratio. - Single-term and full-coverage queries are unchanged (coverage == 1), so identifier lookups keep exact-match dominance (test_score_nodes_coverage_full_coverage_query_is_unchanged pins the exact pre-change score). On a real 5,700-node graph (the #1602 repros): the "home" collision drops from 6087.9 to 63.2 and the relevant cluster takes rank 1-2, so seeds recover it inside the gap window instead of relying solely on the per-term guarantee; the "list" collision drops from 4871.7 to 194.9 with the intended identifier back inside the gap window. Identifier and well-formed natural-language queries produce identical seeds before and after. Known residual, documented in _pick_seeds' docstring: a relevant node matched only via interior substrings still scores below a dampened collision (flat tier ~1 x IDF vs exact/n^2), which is exactly why the #1596 per-term seed guarantee remains load-bearing alongside this. Adds a regression test reproducing the #1602 second repro (the query contains the target's literal identifier yet three same-directory list() leaves outrank it) and a no-change test pinning full-coverage scores. tests/test_serve.py (78 tests) and ruff both pass; the full suite's only failures are environment-specific and reproduce identically on the unpatched base. --- graphify/serve.py | 36 +++++++++++++++++++++++--- tests/test_serve.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/graphify/serve.py b/graphify/serve.py index 28cb46616..96a1b6d13 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -285,7 +285,11 @@ def _trigram_candidates(G: nx.Graph, needles: list[str], *, guard_frac: float = def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: scored = [] - norm_terms = [tok for t in terms for tok in _search_tokens(t)] + # Dedupe tokens, order-preserving (as _pick_seeds already does): a repeated + # query word must not double-count every tier, and with coverage scaling + # below it would also inflate the matched-term ratio (#1602). + norm_terms = list(dict.fromkeys(tok for t in terms for tok in _search_tokens(t))) + n_terms = len(norm_terms) idf = _compute_idf(G, norm_terms) # Whole-query string for full-label matching (mirrors _find_node's `term`). joined = " ".join(norm_terms) @@ -328,18 +332,38 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: or label_tokens.startswith(joined) ): score += _PREFIX_MATCH_BONUS * 10 * joined_w + # Term coverage (#1602): scale the per-term exact/prefix tiers by the + # squared fraction of query terms the node's LABEL matches, so a lone + # generic word that happens to equal a short label (query term "home" + # vs. a home() leaf) cannot bury nodes that match several of the + # query's terms. Squaring matters because the exact tier is 10x the + # prefix tier: at linear coverage a 1-of-10-terms exact match still + # outscores a 3-of-10 prefix+substring match. Single-term and + # full-coverage queries are unchanged (coverage == 1), so identifier + # lookups keep exact-match dominance. Source-file hits score but do + # not count as coverage: a colliding leaf whose directory shares + # tokens with the query (common near the intended target) must not + # win back its exact tier via path fragments. The substring/source + # bonuses and the full-query tier above stay unscaled. + matched = 0 + tiered = 0.0 for t in norm_terms: w = idf.get(t, 1.0) # Three-tier precedence: exact > prefix > substring (take the # strongest tier per term so a single term cannot double-count). if t == norm_label or t == bare_label: - score += _EXACT_MATCH_BONUS * w + tiered += _EXACT_MATCH_BONUS * w + matched += 1 elif norm_label.startswith(t) or bare_label.startswith(t): - score += _PREFIX_MATCH_BONUS * w + tiered += _PREFIX_MATCH_BONUS * w + matched += 1 elif t in norm_label: score += _SUBSTRING_MATCH_BONUS * w + matched += 1 if t in source: score += _SOURCE_MATCH_BONUS * w + if tiered: + score += tiered * (matched / n_terms) ** 2 if score > 0: scored.append((score, nid)) # Sort by score desc; break ties toward the shorter label so a concise exact @@ -378,6 +402,12 @@ def _pick_seeds( collision cannot starve out the others. Ties within a term are broken by graph degree (structural centrality), so an isolated incidental match doesn't out-rank a real, well-connected hub for that term. + + Coverage scaling in _score_nodes (#1602) now dampens a lone collision's + exact tier on multi-term queries, which brings label-matching relevant + nodes back inside the gap window; this per-term guarantee remains + load-bearing for relevant nodes matched only via substrings, whose flat + scores a dampened collision can still exceed. """ if not scored: return [] diff --git a/tests/test_serve.py b/tests/test_serve.py index 2647aa1a8..3f5dd024f 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -8,6 +8,8 @@ _communities_from_graph, _score_nodes, _compute_idf, + _EXACT_MATCH_BONUS, + _SOURCE_MATCH_BONUS, _pick_seeds, _bfs, _dfs, @@ -123,6 +125,66 @@ def _add(nid, label, src): assert scored[0][0] > scored[1][0], "exact label must strictly outrank superset/token-bag matches" +def test_score_nodes_coverage_lone_generic_exact_hit_loses_to_multi_term_match(): + """A lone generic-word exact match must not bury a multi-term match. + + Reproduces #1602: in a multi-term query, a single generic term that + exactly equals a short leaf label (query term "list" vs a list() function + node) received the full exact-tier bonus and outranked every node matching + several of the query's terms, even when the query contained the target's + literal identifier. The per-term exact/prefix tiers are now scaled by + squared term coverage, so a 1-of-5-terms collision drops below a + multi-term match. The leaves live in the same directory as the target + (the realistic case) to pin that source-path hits do not count as + coverage and hand the collision its exact tier back. + """ + G = nx.Graph() + + def _add(nid, label, src): + G.add_node(nid, label=label, norm_label=label.lower(), + source_file=src, community=0) + + _add("target", "ClientLive.Index", "lib/clients_live/index.ex") + _add("form", "ClientLive.Form", "lib/clients_live/form.ex") + _add("show", "ClientLive.Show", "lib/clients_live/show.ex") + # Same-named tiny leaf functions: "list" == bare label fires the exact + # tier. Placed in the target's own directory so their source paths also + # substring-match the query term "clients": a path hit must not inflate + # the coverage that multiplies the exact tier. + for i in range(3): + _add(f"leaf{i}", "list()", f"lib/clients_live/helpers{i}.ex") + # Filler making "list" a common (low-IDF) token, as in a real graph where + # list()/get()/new() style names are ubiquitous. + for i in range(24): + _add(f"filler{i}", f"shopping list {i}", f"lib/filler{i}.ex") + + # The user pastes the real identifier plus context words; tokenization + # yields 5 terms: clientlive, index, clients, list, columns. + scored = _score_nodes(G, [t.lower() for t in "ClientLive.Index clients list columns".split()]) + by_id = {nid: s for s, nid in scored} + + assert scored[0][1] == "target" + assert by_id["target"] > by_id["leaf0"], ( + "a 1-of-5-terms exact collision must not outrank the node matching 3 of 5 terms" + ) + + +def test_score_nodes_coverage_full_coverage_query_is_unchanged(): + """Coverage scaling must not touch full-coverage queries (coverage == 1). + + A single-term identifier lookup keeps the exact tier's full magnitude, so + `query "FooBarService"` behavior is byte-identical to before #1602. + """ + G = _make_graph() + scored = _score_nodes(G, ["extract"]) + w = _compute_idf(G, ["extract"])["extract"] + assert scored[0][1] == "n1" + # Full-query exact tier (10x) + per-term exact tier + source hit + # ("extract" in "extract.py"), all undampened. + expected = (_EXACT_MATCH_BONUS * 10 + _EXACT_MATCH_BONUS + _SOURCE_MATCH_BONUS) * w + assert scored[0][0] == pytest.approx(expected) + + def test_find_node_ignores_trailing_punctuation(): G = _make_graph() assert _find_node(G, "extract?") == ["n1"]