misroute 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. misroute/__init__.py +3 -0
  2. misroute/agent_server.py +98 -0
  3. misroute/check.py +151 -0
  4. misroute/cli.py +460 -0
  5. misroute/config.py +95 -0
  6. misroute/detectors/__init__.py +0 -0
  7. misroute/detectors/codemode.py +60 -0
  8. misroute/detectors/confusion.py +40 -0
  9. misroute/detectors/hedge.py +17 -0
  10. misroute/detectors/nearmiss.py +66 -0
  11. misroute/detectors/rank.py +72 -0
  12. misroute/detectors/removal.py +11 -0
  13. misroute/fix/__init__.py +0 -0
  14. misroute/fix/clause.py +94 -0
  15. misroute/fix/stats.py +52 -0
  16. misroute/guard/__init__.py +0 -0
  17. misroute/guard/proxy.py +44 -0
  18. misroute/guard/rules.py +49 -0
  19. misroute/guard/wrapper.py +119 -0
  20. misroute/lint/__init__.py +0 -0
  21. misroute/lint/descriptions.py +50 -0
  22. misroute/models/__init__.py +16 -0
  23. misroute/models/anthropic.py +70 -0
  24. misroute/models/base.py +79 -0
  25. misroute/models/mock.py +158 -0
  26. misroute/models/openai_compat.py +118 -0
  27. misroute/modes/__init__.py +0 -0
  28. misroute/modes/code_mode.py +87 -0
  29. misroute/modes/json_mode.py +71 -0
  30. misroute/modes/stubs.py +40 -0
  31. misroute/project.py +433 -0
  32. misroute/py.typed +0 -0
  33. misroute/report/__init__.py +0 -0
  34. misroute/report/html.py +46 -0
  35. misroute/report/templates/report.html.j2 +102 -0
  36. misroute/requests/__init__.py +0 -0
  37. misroute/requests/gate.py +107 -0
  38. misroute/requests/generate.py +74 -0
  39. misroute/requests/validate.py +30 -0
  40. misroute/sandbox/__init__.py +0 -0
  41. misroute/sandbox/fake_tools.py +90 -0
  42. misroute/sandbox/monty_runner.py +45 -0
  43. misroute/server/__init__.py +0 -0
  44. misroute/server/catalog.py +110 -0
  45. misroute/server/connect.py +58 -0
  46. misroute/site/__init__.py +0 -0
  47. misroute/site/build.py +41 -0
  48. misroute/site/templates/index.html.j2 +49 -0
  49. misroute/store/__init__.py +0 -0
  50. misroute/store/cache.py +76 -0
  51. misroute/store/runs.py +90 -0
  52. misroute/testing/__init__.py +0 -0
  53. misroute/testing/fake_server.py +56 -0
  54. misroute/trial.py +67 -0
  55. misroute/ui/__init__.py +0 -0
  56. misroute/ui/app.py +164 -0
  57. misroute/ui/index.html +149 -0
  58. misroute-0.2.0.dist-info/METADATA +265 -0
  59. misroute-0.2.0.dist-info/RECORD +62 -0
  60. misroute-0.2.0.dist-info/WHEEL +4 -0
  61. misroute-0.2.0.dist-info/entry_points.txt +3 -0
  62. misroute-0.2.0.dist-info/licenses/LICENSE +21 -0
misroute/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from misroute.project import Project
2
+
3
+ __all__ = ["Project"]
@@ -0,0 +1,98 @@
1
+ from typing import Any
2
+ from mcp.server.mcpserver import MCPServer
3
+ from mcp_types import ToolAnnotations
4
+ from misroute import check as checker
5
+ from misroute.lint import descriptions
6
+ from misroute.models.base import BudgetExceeded
7
+ from misroute.project import Project
8
+ from misroute.store.runs import SCHEMA_VERSION
9
+
10
+ READ = ToolAnnotations(read_only_hint=True)
11
+ PAID = ToolAnnotations(read_only_hint=True, open_world_hint=True)
12
+
13
+
14
+ def create(proj: Project) -> MCPServer:
15
+ app = MCPServer("misroute", instructions="Check MCP tool descriptions against model behaviour. Never edits tools.")
16
+
17
+ @app.tool(
18
+ description="Lists tools on the configured MCP server whose descriptions are missing, only a path, only the "
19
+ "tool name, or very short. Free, makes no model calls. Run it first.",
20
+ annotations=READ,
21
+ structured_output=True,
22
+ )
23
+ async def lint_descriptions() -> dict[str, Any]:
24
+ res = await proj.lint(draft=False)
25
+ return {"schema_version": SCHEMA_VERSION, **res}
26
+
27
+ @app.tool(
28
+ description="Writes a suggested one sentence description for one tool, for a person to review. "
29
+ "Costs one model call and never changes the server.",
30
+ annotations=PAID,
31
+ structured_output=True,
32
+ )
33
+ async def draft_description(tool: str) -> dict[str, Any]:
34
+ tools = await proj.tools()
35
+ by = {t.name: t for t in tools}
36
+ if tool not in by:
37
+ return {"error": f"unknown tool {tool}"}
38
+ text = await descriptions.draft(proj.role("writer"), by[tool], tools)
39
+ return {"schema_version": SCHEMA_VERSION, "tool": tool, "draft": text, "applied": False}
40
+
41
+ @app.tool(
42
+ description="Returns the number of model calls and the estimated dollar cost of check_description for "
43
+ "one tool, without running it. Free.",
44
+ annotations=READ,
45
+ structured_output=True,
46
+ )
47
+ async def estimate_check(tool: str) -> dict[str, Any]:
48
+ calls, cost = checker.estimate(
49
+ proj, 2 + len(checker.neighbours(proj, tool)), proj.cfg.tested(), ["json"], await proj.tools()
50
+ )
51
+ return {"schema_version": SCHEMA_VERSION, "calls": calls, "cost_usd": round(cost, 4)}
52
+
53
+ @app.tool(
54
+ description="Tests whether a proposed description for one tool routes requests better than the current "
55
+ "one, on held out requests. Returns better, worse, no_difference or insufficient_data with trial counts. "
56
+ "Refuses to run when the estimate is above max_cost. Never changes the server.",
57
+ annotations=PAID,
58
+ structured_output=True,
59
+ )
60
+ async def check_description(
61
+ tool: str, description: str, max_cost: float = 1.0, refresh: bool = False
62
+ ) -> dict[str, Any]:
63
+ _, cost = checker.estimate(
64
+ proj, 2 + len(checker.neighbours(proj, tool)), proj.cfg.tested(), ["json"], await proj.tools()
65
+ )
66
+ if cost > max_cost:
67
+ return {"error": f"estimate ${cost:.2f} is above max_cost ${max_cost:.2f}", "cost_usd": round(cost, 4)}
68
+ try:
69
+ res = await checker.check(proj, tool, description, refresh=refresh)
70
+ except (BudgetExceeded, KeyError) as e:
71
+ return {"error": str(e)}
72
+ return {"schema_version": SCHEMA_VERSION, "estimate_usd": round(cost, 4), **res}
73
+
74
+ @app.tool(
75
+ description="Returns the latest discover, probe and fix results as JSON: risky tool pairs, wrong pick and "
76
+ "hedge rates per model and mode, and fix verdicts. Free.",
77
+ annotations=READ,
78
+ structured_output=True,
79
+ )
80
+ async def get_report() -> dict[str, Any]:
81
+ found: dict[str, Any] = proj.store.read("discover") or {}
82
+ fixed: dict[str, Any] = proj.store.read("fix") or {}
83
+ cells = [
84
+ {k: c[k] for k in ("model", "mode", "trials", "wrong_rate", "hedge_rate", "failures")}
85
+ for c in found.get("cells", [])
86
+ ]
87
+ return {
88
+ "schema_version": SCHEMA_VERSION,
89
+ "cells": cells,
90
+ "spots": found.get("spots", [])[:10],
91
+ "fix": [
92
+ {k: r.get(k) for k in ("pair", "model", "mode", "baseline_kind", "errors", "verdict")}
93
+ for r in fixed.get("pairs", [])
94
+ ],
95
+ "stale": proj.store.stale(),
96
+ }
97
+
98
+ return app
misroute/check.py ADDED
@@ -0,0 +1,151 @@
1
+ import hashlib
2
+ from typing import Any
3
+ from misroute.detectors import confusion, hedge
4
+ from misroute.fix import clause, stats
5
+ from misroute.lint import descriptions
6
+ from misroute.server.catalog import Tool, replace_descriptions
7
+ from misroute.trial import Request, Trial
8
+
9
+ MAX_VERDICTS = 3
10
+ MIN_DISCORDANT = 6
11
+
12
+
13
+ def split(req: Request, salt: int) -> str:
14
+ h = int(hashlib.sha256(f"{req.id}|{salt}".encode()).hexdigest()[:8], 16)
15
+ return "held_out" if h % 2 else "tuning"
16
+
17
+
18
+ def neighbours(proj, name: str, limit: int = 3) -> list[str]:
19
+ found = proj.store.read("discover") or {}
20
+ out = [s["b"] if s["a"] == name else s["a"] for s in found.get("spots", []) if name in (s["a"], s["b"])]
21
+ out += list((found.get("removal") or {}).get(name, {}))
22
+ return [n for n in dict.fromkeys(out) if n not in (name, "(none)")][:limit]
23
+
24
+
25
+ def rates(trials: list[Trial], focus: set[str]) -> dict[str, Any]:
26
+ n = len(trials) or 1
27
+ leak = confusion.Counter(t.chosen for t in trials if t.chosen and t.chosen not in focus)
28
+ return {
29
+ "trials": len(trials),
30
+ "errors": sum(t.error for t in trials),
31
+ "wrong_rate": sum(t.wrong for t in trials) / n,
32
+ "hedge_rate": hedge.hedge_rate(trials),
33
+ "leakage": dict(leak),
34
+ }
35
+
36
+
37
+ def decide(base: dict, prop: dict) -> tuple[str, str, int, int, float]:
38
+ fixed, broke, p = stats.mcnemar(base, prop)
39
+ if fixed + broke < MIN_DISCORDANT:
40
+ return "insufficient_data", f"{fixed + broke} discordant trials, need {MIN_DISCORDANT}", fixed, broke, p
41
+ if p >= 0.05:
42
+ return "no_difference", f"p {p:.3f}", fixed, broke, p
43
+ return ("better" if fixed > broke else "worse"), f"p {p:.3f}", fixed, broke, p
44
+
45
+
46
+ async def check(
47
+ proj,
48
+ tool_name: str,
49
+ proposed: str,
50
+ current: str | None = None,
51
+ refresh: bool = False,
52
+ models_: list[str] | None = None,
53
+ modes: list[str] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Compare a proposed description with the current one and a baseline. The verdict uses held out requests."""
56
+ models_ = models_ or proj.cfg.tested()
57
+ modes = modes or ["json"]
58
+ tools = await proj.tools()
59
+ by = {t.name: t for t in tools}
60
+ if tool_name not in by:
61
+ raise KeyError(f"unknown tool {tool_name}")
62
+ if current is not None:
63
+ tools = replace_descriptions(tools, {tool_name: current})
64
+ by = {t.name: t for t in tools}
65
+ target = by[tool_name]
66
+ state = proj.store.read("checks") or {}
67
+ entry = state.get(tool_name, {"round": 0, "verdicts": 0})
68
+ if refresh:
69
+ entry = {"round": entry["round"] + 1, "verdicts": 0}
70
+ spent = entry["verdicts"] >= MAX_VERDICTS
71
+ near = neighbours(proj, tool_name)
72
+ focus = {tool_name, *near}
73
+ reqs: list[Request] = []
74
+ for t in [target, *[by[n] for n in near if n in by]]:
75
+ reqs += await proj.requests_for(t, tools, seed=entry["round"])
76
+ if near:
77
+ reqs += await proj.requests_for(target, tools, by[near[0]], seed=entry["round"])
78
+ halves = {"tuning": [r for r in reqs if split(r, entry["round"]) == "tuning"]}
79
+ halves["held_out"] = [r for r in reqs if split(r, entry["round"]) == "held_out"]
80
+ flagged = descriptions.classify(target)
81
+ writer = proj.role("writer")
82
+ if flagged:
83
+ baseline = await descriptions.draft(writer, target, tools)
84
+ kind = "description"
85
+ else:
86
+ baseline = await clause.neutral_one(writer, target, len(proposed))
87
+ kind = "neutral"
88
+ conds = {
89
+ "current": tools,
90
+ "baseline": replace_descriptions(tools, {tool_name: baseline}),
91
+ "proposed": replace_descriptions(tools, {tool_name: proposed}),
92
+ }
93
+ out: dict[str, Any] = {"tool": tool_name, "neighbours": near, "baseline_kind": kind, "baseline": baseline}
94
+ start = proj.budget.spent
95
+ use = ["tuning"] if spent else ["tuning", "held_out"]
96
+ for name in use:
97
+ rs = halves[name]
98
+ res: dict[str, Any] = {}
99
+ errs: dict[str, dict] = {}
100
+ for cond, ts in conds.items():
101
+ orders = [ts, ts[::-1]]
102
+ trials = await proj.run_trials(
103
+ proj.pair_trials(rs, models_, modes, proj.cfg.slots, phase="check", condition=cond),
104
+ lambda t, orders=orders: orders[t.order],
105
+ )
106
+ res[cond] = rates(trials, focus)
107
+ errs[cond] = {t.key: t.error for t in trials}
108
+ out[name] = res
109
+ out[f"{name}_errors"] = errs
110
+ if spent:
111
+ verdict, reason = "insufficient_data", f"held out set used for {MAX_VERDICTS} verdicts, rerun with refresh"
112
+ fixed = broke = 0
113
+ p = 1.0
114
+ else:
115
+ verdict, reason, fixed, broke, p = decide(
116
+ out["held_out_errors"]["baseline"], out["held_out_errors"]["proposed"]
117
+ )
118
+ vs_current = decide(out["held_out_errors"]["current"], out["held_out_errors"]["proposed"])
119
+ out["vs_current"] = {
120
+ "verdict": vs_current[0],
121
+ "fixed": vs_current[2],
122
+ "broke": vs_current[3],
123
+ "p": vs_current[4],
124
+ }
125
+ entry["verdicts"] += 1
126
+ state[tool_name] = entry
127
+ proj.store.write("checks", state)
128
+ for name in use:
129
+ out.pop(f"{name}_errors")
130
+ held = out.get("held_out", {})
131
+ out.update(
132
+ verdict=verdict,
133
+ reason=reason,
134
+ held_out=not spent,
135
+ fixed=fixed,
136
+ broke=broke,
137
+ p=p,
138
+ trials=held.get("proposed", {}).get("trials", 0),
139
+ wrong_rate={c: v["wrong_rate"] for c, v in held.items()},
140
+ hedge_rate={c: v["hedge_rate"] for c, v in held.items()},
141
+ leakage=sorted({k for v in held.values() for k in v["leakage"]}),
142
+ verdicts_left=max(MAX_VERDICTS - entry["verdicts"], 0),
143
+ round=entry["round"],
144
+ cost_usd=round(proj.budget.spent - start, 4),
145
+ )
146
+ return out
147
+
148
+
149
+ def estimate(proj, n_tools: int, models_: list[str], modes: list[str], tools: list[Tool]) -> tuple[int, float]:
150
+ per_model = n_tools * proj.cfg.requests_per_tool * proj.cfg.slots * 2 * 3 * len(modes)
151
+ return per_model * len(models_), sum(proj.estimate(per_model, m, tools) for m in models_)