mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 06:10:58 +08:00
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth, per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta. - Mount the official MCP Streamable HTTP app at /mcp (stateless); AuthAuditMiddleware is pure ASGI and skips /mcp. - Let uvicorn chunk MCP responses; some Windows LSPs rewrite Content-Length replies on */mcp* paths into broken chunked framing. - NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default so LAN and reverse-proxy hosts work; Bearer is the gate). - Remove the three stdio Python MCP packages; tests moved to /mcp, including real-TCP framing regressions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""Mid-tier topology geometry QA: chain cohesion + min-ring integrity.
|
||
|
||
- chain(直链成一体): deg≤2 corridors should lay nearly collinear as one unit.
|
||
- rings(最小环不被穿): short chordless cycles should not be crossed by foreign edges.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from collections import defaultdict, deque
|
||
from typing import Any
|
||
|
||
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||
|
||
# Turn from 180° beyond this → kink (degrees).
|
||
CHAIN_KINK_DEG = 40.0
|
||
# Min nodes on a corridor to score as a chain (hub + ≥2 hops, or ≥3 deg≤2).
|
||
CHAIN_MIN_NODES = 3
|
||
# Chordless cycles longer than this are ignored (metro min-rings are short).
|
||
RING_MAX_LEN = 8
|
||
|
||
|
||
def _adj(links: list[tuple[str, str]]) -> dict[str, set[str]]:
|
||
g: dict[str, set[str]] = defaultdict(set)
|
||
for a, b in links:
|
||
if a == b:
|
||
continue
|
||
g[a].add(b)
|
||
g[b].add(a)
|
||
return g
|
||
|
||
|
||
def _edge_key(a: str, b: str) -> tuple[str, str]:
|
||
return (a, b) if a < b else (b, a)
|
||
|
||
|
||
def _walk_path(start: str, nodes: set[str], adj: dict[str, set[str]]) -> list[str]:
|
||
ordered = [start]
|
||
seen = {start}
|
||
prev: str | None = None
|
||
cur = start
|
||
while True:
|
||
nbs = [v for v in adj.get(cur, ()) if v in nodes and v not in seen]
|
||
if not nbs:
|
||
break
|
||
# Prefer continuing along the unique unused neighbor.
|
||
nxt = nbs[0] if len(nbs) == 1 else sorted(nbs)[0]
|
||
if prev in nbs and len(nbs) > 1:
|
||
nbs = [v for v in nbs if v != prev]
|
||
nxt = nbs[0]
|
||
prev, cur = cur, nxt
|
||
ordered.append(cur)
|
||
seen.add(cur)
|
||
return ordered
|
||
|
||
|
||
def extract_chain_paths(adj: dict[str, set[str]]) -> list[list[str]]:
|
||
"""Maximal deg≤2 corridors, optionally extended by one hub endpoint each side."""
|
||
if not adj:
|
||
return []
|
||
deg = {u: len(vs) for u, vs in adj.items()}
|
||
low = {u for u, d in deg.items() if 0 < d <= 2}
|
||
if not low:
|
||
return []
|
||
|
||
seen: set[str] = set()
|
||
chains: list[list[str]] = []
|
||
for seed in sorted(low):
|
||
if seed in seen:
|
||
continue
|
||
q = deque([seed])
|
||
seen.add(seed)
|
||
comp: set[str] = set()
|
||
while q:
|
||
u = q.popleft()
|
||
comp.add(u)
|
||
for v in adj.get(u, ()):
|
||
if v in low and v not in seen:
|
||
seen.add(v)
|
||
q.append(v)
|
||
|
||
# Cycle of only low-deg nodes → not a chain corridor.
|
||
sub_deg = {u: sum(1 for v in adj.get(u, ()) if v in comp) for u in comp}
|
||
ends = [u for u in comp if sub_deg[u] <= 1]
|
||
if len(ends) == 0 and len(comp) >= 3:
|
||
continue
|
||
|
||
start = sorted(ends)[0] if ends else sorted(comp)[0]
|
||
ordered = _walk_path(start, comp, adj)
|
||
if len(ordered) < len(comp):
|
||
# branched low-deg blob: take longest path approximation via ends
|
||
best = ordered
|
||
for e in ends:
|
||
p = _walk_path(e, comp, adj)
|
||
if len(p) > len(best):
|
||
best = p
|
||
ordered = best
|
||
|
||
# Extend with unique high-deg portals (hub ends).
|
||
def _portal(end: str) -> str | None:
|
||
outs = [v for v in adj.get(end, ()) if v not in comp]
|
||
return outs[0] if len(outs) == 1 else None
|
||
|
||
if ordered:
|
||
left = _portal(ordered[0])
|
||
right = _portal(ordered[-1])
|
||
if left:
|
||
ordered = [left] + ordered
|
||
if right and right != left:
|
||
ordered = ordered + [right]
|
||
|
||
if len(ordered) >= CHAIN_MIN_NODES:
|
||
chains.append(ordered)
|
||
return chains
|
||
|
||
|
||
def _chain_straightness(path: list[str], pos: dict[str, tuple[float, float]]) -> float:
|
||
pts = [pos[n] for n in path if n in pos]
|
||
if len(pts) < 2:
|
||
return 1.0
|
||
chord = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
|
||
plen = 0.0
|
||
for i in range(len(pts) - 1):
|
||
plen += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
|
||
if plen < 1e-9:
|
||
return 1.0
|
||
return max(0.0, min(1.0, chord / plen))
|
||
|
||
|
||
def _chain_kinks(path: list[str], pos: dict[str, tuple[float, float]]) -> int:
|
||
pts = [pos[n] for n in path if n in pos]
|
||
kinks = 0
|
||
for i in range(1, len(pts) - 1):
|
||
ax, ay = pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]
|
||
bx, by = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]
|
||
la, lb = math.hypot(ax, ay), math.hypot(bx, by)
|
||
if la < 1e-9 or lb < 1e-9:
|
||
continue
|
||
cos = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
|
||
turn = abs(math.degrees(math.acos(cos)) - 0.0) # 0 = straight continuation
|
||
# acos of dot gives angle between directions; 0° = collinear same way.
|
||
# kink if direction changes more than CHAIN_KINK_DEG from straight (0°).
|
||
if turn > CHAIN_KINK_DEG:
|
||
kinks += 1
|
||
return kinks
|
||
|
||
|
||
def compute_chain_cohesion(
|
||
pos: dict[str, tuple[float, float]],
|
||
links: list[tuple[str, str]],
|
||
) -> dict[str, Any]:
|
||
"""直链成一体:corridor straightness / kink rate → score∈[0,1]."""
|
||
adj = _adj(links)
|
||
chains = extract_chain_paths(adj)
|
||
if not chains:
|
||
return {
|
||
"chain_count": 0,
|
||
"chain_nodes": 0,
|
||
"straightness_p50": None,
|
||
"kink_count": 0,
|
||
"kink_frac": 0.0,
|
||
"score": 1.0,
|
||
"tip": "无 deg≤2 走廊可评;不扣分。",
|
||
}
|
||
|
||
straight: list[float] = []
|
||
weights: list[float] = []
|
||
kink_total = 0
|
||
kink_slots = 0
|
||
node_union: set[str] = set()
|
||
for path in chains:
|
||
node_union.update(path)
|
||
s = _chain_straightness(path, pos)
|
||
straight.append(s)
|
||
weights.append(max(1, len(path) - 1))
|
||
k = _chain_kinks(path, pos)
|
||
kink_total += k
|
||
kink_slots += max(0, len([n for n in path if n in pos]) - 2)
|
||
|
||
# weighted mean straightness
|
||
wsum = sum(weights) or 1.0
|
||
mean_s = sum(s * w for s, w in zip(straight, weights)) / wsum
|
||
sorted_s = sorted(straight)
|
||
p50 = sorted_s[len(sorted_s) // 2]
|
||
kink_frac = (kink_total / kink_slots) if kink_slots else 0.0
|
||
# Mid-tier score: mostly straightness, penalize kinks.
|
||
score = max(0.0, min(1.0, 0.75 * mean_s + 0.25 * (1.0 - kink_frac)))
|
||
|
||
return {
|
||
"chain_count": len(chains),
|
||
"chain_nodes": len(node_union),
|
||
"straightness_p50": round(p50, 4),
|
||
"straightness_mean": round(mean_s, 4),
|
||
"kink_count": kink_total,
|
||
"kink_frac": round(kink_frac, 4),
|
||
"score": round(score, 4),
|
||
"tip": (
|
||
"deg≤2 走廊应近似共线成一体(chord/path≈1、少折角);"
|
||
f"折角阈值 {CHAIN_KINK_DEG:.0f}°。"
|
||
),
|
||
}
|
||
|
||
|
||
def _is_chordless(cycle: list[str], adj: dict[str, set[str]]) -> bool:
|
||
n = len(cycle)
|
||
idx = {u: i for i, u in enumerate(cycle)}
|
||
for i, u in enumerate(cycle):
|
||
for v in adj.get(u, ()):
|
||
j = idx.get(v)
|
||
if j is None:
|
||
continue
|
||
dist = min((j - i) % n, (i - j) % n)
|
||
if dist > 1:
|
||
return False
|
||
return True
|
||
|
||
|
||
def find_short_chordless_cycles(
|
||
adj: dict[str, set[str]],
|
||
*,
|
||
max_len: int = RING_MAX_LEN,
|
||
max_cycles: int = 120,
|
||
) -> list[list[str]]:
|
||
"""Enumerate chordless cycles of length 3..max_len (canonicalized).
|
||
|
||
Caps at ``max_cycles`` so giant star canvases stay interactive.
|
||
"""
|
||
if not adj:
|
||
return []
|
||
cycles: list[list[str]] = []
|
||
seen: set[frozenset[str]] = set()
|
||
nodes = sorted(adj.keys())
|
||
for start in nodes:
|
||
if len(cycles) >= max_cycles:
|
||
break
|
||
# path grows; first neighbor ordered to cut duplicates
|
||
for first in sorted(adj.get(start, ())):
|
||
if first <= start:
|
||
continue
|
||
if len(cycles) >= max_cycles:
|
||
break
|
||
stack: list[tuple[str, list[str]]] = [(first, [start, first])]
|
||
while stack:
|
||
if len(cycles) >= max_cycles:
|
||
break
|
||
u, path = stack.pop()
|
||
if len(path) > max_len:
|
||
continue
|
||
for v in adj.get(u, ()):
|
||
if v == start and len(path) >= 3:
|
||
key = frozenset(path)
|
||
if key in seen:
|
||
continue
|
||
if _is_chordless(path, adj):
|
||
seen.add(key)
|
||
cycles.append(list(path))
|
||
if len(cycles) >= max_cycles:
|
||
break
|
||
continue
|
||
if v in path or v < start:
|
||
continue
|
||
if len(path) + 1 > max_len:
|
||
continue
|
||
stack.append((v, path + [v]))
|
||
return cycles
|
||
|
||
|
||
def compute_ring_integrity(
|
||
pos: dict[str, tuple[float, float]],
|
||
links: list[tuple[str, str]],
|
||
) -> dict[str, Any]:
|
||
"""最小环不被穿:foreign edges crossing a short cycle's boundary."""
|
||
adj = _adj(links)
|
||
cycles = find_short_chordless_cycles(adj, max_len=RING_MAX_LEN)
|
||
if not cycles:
|
||
return {
|
||
"ring_count": 0,
|
||
"rings_pierced": 0,
|
||
"pierce_crossings": 0,
|
||
"score": 1.0,
|
||
"tip": "无长度≤8 的弦无关短环;不扣分。",
|
||
}
|
||
|
||
pierced = 0
|
||
pierce_crossings = 0
|
||
for cyc in cycles:
|
||
cyc_edges = {
|
||
_edge_key(cyc[i], cyc[(i + 1) % len(cyc)]) for i in range(len(cyc))
|
||
}
|
||
hit = False
|
||
for a, b in links:
|
||
ek = _edge_key(a, b)
|
||
if ek in cyc_edges:
|
||
continue
|
||
if a not in pos or b not in pos:
|
||
continue
|
||
pa, pb = pos[a], pos[b]
|
||
for u, v in cyc_edges:
|
||
if u not in pos or v not in pos:
|
||
continue
|
||
if segments_properly_intersect(pa, pb, pos[u], pos[v]):
|
||
pierce_crossings += 1
|
||
hit = True
|
||
break
|
||
# continue scanning to count pierce_crossings
|
||
if hit:
|
||
pierced += 1
|
||
|
||
n = len(cycles)
|
||
# Soften: many pierce events on one ring still one pierced ring;
|
||
# also decay by pierce density.
|
||
pierce_frac = pierced / n
|
||
dens = pierce_crossings / max(1, n)
|
||
score = max(0.0, min(1.0, 1.0 - 0.7 * pierce_frac - 0.3 * min(1.0, dens / 3.0)))
|
||
|
||
return {
|
||
"ring_count": n,
|
||
"rings_pierced": pierced,
|
||
"pierce_crossings": pierce_crossings,
|
||
"score": round(score, 4),
|
||
"tip": (
|
||
f"弦无关短环(3–{RING_MAX_LEN})边界不应被环外边穿越;"
|
||
"rings_pierced / pierce_crossings 越低越好。"
|
||
),
|
||
}
|
||
|
||
|
||
def compute_topology_quality(
|
||
pos: dict[str, tuple[float, float]],
|
||
links: list[tuple[str, str]],
|
||
) -> dict[str, Any]:
|
||
chain = compute_chain_cohesion(pos, links)
|
||
rings = compute_ring_integrity(pos, links)
|
||
return {"chains": chain, "rings": rings}
|