Skip to content

Commit e8053cf

Browse files
@ (#823)
fix(mcp): defer answer-by-union to synthesis when the mention is incidental A prose get_answer question that merely mentions a generic method with many same-named definitions (to_dict, from_dict, provider_name) was hijacked into the answer-by-union exact-name path, returning the whole def set as grounding=exact_symbol / confidence=high ("use directly, no Read") and skipping synthesis of the actual question. Measured on the live index, "how does a wiki page get its provider_name during indexing?" returned 12 unrelated provider stubs; a to_dict mention returned 28. The union is still the right answer for a small set of genuine parallel implementations the question is about (_severity_for has 4 across the biomarkers), and for an explicit bare-symbol lookup. Prose dominance alone does not separate those from the misfires (the _severity_for question is equally prose with one identifier), so gate on both signals: defer only when the query reads as prose AND the def count exceeds a small ceiling. Narrowest possible population is affected; small unions and bare lookups are untouched. - config: _HOMONYM_UNION_PROSE_DEF_CEILING (6) - symbols: union_defers_to_synthesis, reusing the query prose-dominance helper - answer: clear union_groups when the mention is incidental, falling through to the existing synthesis path - tests: helper keep/defer/bare/boundary, plus end-to-end defer-synthesizes and small-union-still-unions @
1 parent a3ae800 commit e8053cf

4 files changed

Lines changed: 153 additions & 0 deletions

File tree

packages/server/src/repowise/server/mcp_server/tool_answer/answer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
_hydrate_symbols_for_hits,
125125
_read_symbol_source,
126126
build_homonym_union_bodies,
127+
union_defers_to_synthesis,
127128
)
128129
from repowise.server.mcp_server.tool_answer.synthesis import (
129130
_hash_question,
@@ -663,7 +664,14 @@ async def get_answer(
663664
# the agent picks the one it wants from material already in-hand. This is
664665
# the fix for the retrieval-MISS class: those defs are never in the fuzzy
665666
# candidate set, so the exact-name scan is the only thing that surfaces them.
667+
# Defer to synthesis when the union is incidental: a prose question that
668+
# merely mentions a many-def generic method (``to_dict``, ``provider_name``)
669+
# would otherwise dump every unrelated body as a confidence=high answer,
670+
# burying what was actually asked. A bare symbol lookup, or a small genuine
671+
# parallel-impl set (``_severity_for`` x4), still answers by union.
666672
union_groups = homonyms.get("union") or {}
673+
if union_groups and union_defers_to_synthesis(question, question_ids, union_groups):
674+
union_groups = {}
667675
if union_groups:
668676
repo_root = Path(str(ctx.path)) if getattr(ctx, "path", None) else None
669677
union_bodies, more_defs = build_homonym_union_bodies(repo_root, union_groups)

packages/server/src/repowise/server/mcp_server/tool_answer/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@
6565
_HOMONYM_UNION_CHAR_BUDGET = 12000
6666
# Line cap per union body — same rationale as _INLINE_BODY_MAX_LINES.
6767
_HOMONYM_UNION_BODY_MAX_LINES = 120
68+
# Ceiling on how many same-named defs a *prose* question may answer-by-union.
69+
# The union is for a small set of genuine parallel implementations of one concept
70+
# (``_severity_for`` has 4 across the biomarkers); past a handful, the name is a
71+
# generic method implemented on many unrelated classes (``to_dict`` x33,
72+
# ``from_dict`` x26, ``provider_name`` x12), and inlining every body as a
73+
# confidence=high answer buries the actual question. So a prose question that
74+
# merely *mentions* such a name (measured: "how does a wiki page get its
75+
# provider_name during indexing?" dumped 12 unrelated provider stubs) falls
76+
# through to synthesis, which grounds in the file the question is really about.
77+
# An explicit symbol lookup (a bare name, where prose does not dominate) still
78+
# unions at any count — that caller asked for every definition. The gap between a
79+
# genuine union (<=4 seen) and a generic method (>=12 seen) is wide, so this is
80+
# not tuned to an exact count.
81+
_HOMONYM_UNION_PROSE_DEF_CEILING = 6
6882

6983
# Data-shape grounding. "what fields does each entry in <blob> contain" is
7084
# answered directly by mining the field set from source instead of gating to a

packages/server/src/repowise/server/mcp_server/tool_answer/symbols.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
_HIGH_CONFIDENCE_SCORE_FLOOR,
2020
_HOMONYM_UNION_BODY_MAX_LINES,
2121
_HOMONYM_UNION_CHAR_BUDGET,
22+
_HOMONYM_UNION_PROSE_DEF_CEILING,
2223
_MATCHED_SYMBOL_SOURCE_LINES,
2324
_MAX_RICH_SIG_LINES,
2425
_MAX_SYMBOLS_PER_HIT,
@@ -27,6 +28,7 @@
2728
_SYNTH_FULL_BODY_MAX_SYMBOLS,
2829
_SYNTH_FULL_SOURCE_LINES,
2930
)
31+
from repowise.server.mcp_server.tool_search import _prose_dominates
3032

3133

3234
def _extract_question_identifiers(question: str) -> set[str]:
@@ -67,6 +69,38 @@ def _extract_question_identifiers(question: str) -> set[str]:
6769
return ids
6870

6971

72+
def union_defers_to_synthesis(
73+
question: str, question_ids: set[str], union_groups: dict
74+
) -> bool:
75+
"""True when an answer-by-union should fall through to synthesis.
76+
77+
Answer-by-union is the right reply for a small set of genuine parallel
78+
implementations the question is actually about (``_severity_for`` has 4
79+
across the biomarkers). It is the WRONG reply when a prose question merely
80+
*mentions* a generic method that happens to have many definitions: measured,
81+
"how does a wiki page get its provider_name during indexing?" dumped 12
82+
unrelated provider stubs as a confidence=high answer, and a ``to_dict``
83+
mention dumped 28. Two signals must both hold before deferring, so the
84+
narrowest population is affected:
85+
86+
* ``_prose_dominates`` — the query reads as prose, not a bare symbol lookup.
87+
A bare ``provider_name`` (prose does not dominate) still unions: that
88+
caller explicitly asked for every definition.
89+
* the def count exceeds ``_HOMONYM_UNION_PROSE_DEF_CEILING`` — past a
90+
handful, the name is a generic method, not a small parallel-impl set.
91+
92+
Small genuine unions and explicit lookups are untouched; only a prose
93+
question naming a many-def generic method falls through to synthesis (which
94+
grounds in the file the question is really about).
95+
"""
96+
if not union_groups:
97+
return False
98+
total_defs = sum(len(defs) for defs in union_groups.values())
99+
if total_defs <= _HOMONYM_UNION_PROSE_DEF_CEILING:
100+
return False
101+
return _prose_dominates(question, list(question_ids))
102+
103+
70104
def _read_repo_text(repo_root: Path | None, file_path: str) -> str | None:
71105
"""Read a repo file's live text, refusing paths outside the root.
72106

tests/unit/server/mcp/test_answer_calibration.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,103 @@ def test_union_bodies_overflow_lists_remainder_as_pointers(tmp_path):
704704
assert "do NOT Read" in more[0]["hint"]
705705

706706

707+
# ---------------------------------------------------------------------------
708+
# Answer-by-union incidental gate — a prose question that merely MENTIONS a
709+
# many-def generic method (to_dict x33) must not dump every unrelated body as a
710+
# confidence=high answer; it falls through to synthesis. A small genuine union
711+
# (_severity_for x4) and an explicit bare-symbol lookup still answer by union.
712+
# ---------------------------------------------------------------------------
713+
714+
715+
def _union(name: str, n: int) -> dict:
716+
return {name: [{"file_path": f"pkg/f{i}.py", "name": name} for i in range(n)]}
717+
718+
719+
def test_union_defers_only_when_prose_and_many_defs():
720+
from repowise.server.mcp_server.tool_answer.symbols import union_defers_to_synthesis
721+
722+
q_prose = "How does a wiki page get its provider_name during indexing?"
723+
# Prose + many defs → defer to synthesis.
724+
assert union_defers_to_synthesis(q_prose, {"provider_name"}, _union("provider_name", 12))
725+
# Small genuine union stays (the _severity_for x4 case) even in prose.
726+
assert not union_defers_to_synthesis(
727+
"How does _severity_for compute a severity level?",
728+
{"_severity_for"},
729+
_union("_severity_for", 4),
730+
)
731+
# Bare symbol lookup (prose does not dominate) still unions at any count.
732+
assert not union_defers_to_synthesis("provider_name", {"provider_name"}, _union("provider_name", 12))
733+
# No union → nothing to defer.
734+
assert not union_defers_to_synthesis(q_prose, {"provider_name"}, {})
735+
736+
737+
def test_union_defer_ceiling_is_inclusive():
738+
from repowise.server.mcp_server.tool_answer.config import _HOMONYM_UNION_PROSE_DEF_CEILING
739+
from repowise.server.mcp_server.tool_answer.symbols import union_defers_to_synthesis
740+
741+
q = "How is a parsed record serialized with widget_dump before it is stored?"
742+
ids = {"widget_dump"}
743+
at = _HOMONYM_UNION_PROSE_DEF_CEILING
744+
# At the ceiling: still a handful, keep the union.
745+
assert not union_defers_to_synthesis(q, ids, _union("widget_dump", at))
746+
# One past it: a generic method, defer.
747+
assert union_defers_to_synthesis(q, ids, _union("widget_dump", at + 1))
748+
749+
750+
def _patch_anchor_union(monkeypatch, answer_mod, union_groups: dict):
751+
"""Force _anchor_symbol_hits to report a homonym union (no hit boost)."""
752+
753+
async def _fake_anchor(session, repo_id, question_ids, hits, **kwargs):
754+
return hits, {"union": union_groups, "qualified_miss": []}
755+
756+
monkeypatch.setattr(answer_mod, "_anchor_symbol_hits", _fake_anchor)
757+
758+
759+
@pytest.mark.asyncio
760+
async def test_prose_mention_of_generic_method_synthesizes(setup_mcp, monkeypatch):
761+
"""A prose question naming a 12-def generic method falls through to synthesis
762+
instead of returning grounding='exact_symbol' with a wall of unrelated bodies."""
763+
import repowise.server.mcp_server.tool_answer.answer as answer_mod
764+
from repowise.server.mcp_server import get_answer
765+
766+
_patch_pipeline(monkeypatch, answer_mod, with_symbols=False)
767+
_patch_anchor_union(monkeypatch, answer_mod, _union("to_dict", 12))
768+
_patch_provider(monkeypatch, answer_mod, "The page is serialized in pkg/alpha/one.py.")
769+
770+
result = await get_answer("How is a parsed file turned into a dict with to_dict before it is stored?")
771+
assert result.get("grounding") != "exact_symbol"
772+
assert "symbol_bodies" not in result or len(result.get("symbol_bodies") or []) < 12
773+
774+
775+
@pytest.mark.asyncio
776+
async def test_small_union_still_answers_by_union(setup_mcp, monkeypatch, tmp_path):
777+
"""A small genuine parallel-impl union (3 defs, under the ceiling) still
778+
short-circuits to grounding='exact_symbol' — the gate must not over-suppress."""
779+
import repowise.server.mcp_server as mcp_mod
780+
import repowise.server.mcp_server.tool_answer.answer as answer_mod
781+
from repowise.server.mcp_server import get_answer
782+
783+
(tmp_path / "pkg").mkdir(parents=True)
784+
defs = []
785+
for i in range(3):
786+
(tmp_path / "pkg" / f"f{i}.py").write_text(
787+
f"class C:\n def render_widget(self):\n return {i}\n",
788+
encoding="utf-8",
789+
)
790+
defs.append(
791+
{"file_path": f"pkg/f{i}.py", "name": "render_widget", "start_line": 2, "end_line": 3}
792+
)
793+
monkeypatch.setattr(mcp_mod, "_repo_path", str(tmp_path))
794+
795+
_patch_pipeline(monkeypatch, answer_mod, with_symbols=False)
796+
_patch_anchor_union(monkeypatch, answer_mod, {"render_widget": defs})
797+
_patch_provider(monkeypatch, answer_mod, "unused — union short-circuits before synthesis")
798+
799+
result = await get_answer("How does render_widget build its output?")
800+
assert result.get("grounding") == "exact_symbol"
801+
assert len(result["symbol_bodies"]) == 3
802+
803+
707804
# ---------------------------------------------------------------------------
708805
# code_rationale — the T4 lever: in-code rationale recovered when the wiki /
709806
# decision corpus could not ground the question (low-confidence exits).

0 commit comments

Comments
 (0)