MCP: serve tools from the API process over Streamable HTTP at /mcp

- 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>
This commit is contained in:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,781 @@
"""Inward densify sweep: top-3 single-node pulls + corridor_cap scan.
Agent workflow: preview → pick rank 1..3 → apply; round=true auto-applies #1
when global crossings do not rise and stretch falls; phase=corridor / corridor=true
sweeps shrink_long_corridors params for util gain.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
crossings_involving_node,
)
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_diversify_top,
_eval_candidate,
_incident_stretch,
)
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
shrink_long_corridors,
)
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_PULL = 900.0
_MAX_PULL_CAP = 2200.0
_PULL_FRACS = (0.12, 0.22, 0.35, 0.5, 0.7)
_ANGLE_JITTERS = (-30.0, -15.0, 0.0, 15.0, 30.0)
_DEFAULT_CORRIDOR_CAPS = (1200.0, 1600.0, 2200.0, 3200.0)
_DEFAULT_PULLS = (0.35, 0.5, 0.65)
def _protect_is_off(protect_rigid: bool | str) -> bool:
return protect_rigid in (False, "false", "off", "none", "0")
def _resolve_frozen(
st: LayoutState,
protect_rigid: bool | str,
frozen_ids: set[str] | None,
) -> set[str]:
"""Portal/rigid freeze only when protect is on; off ignores portal_ids inject."""
if _protect_is_off(protect_rigid):
return set()
frozen: set[str] = set(frozen_ids or ())
if not frozen:
frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen
def _groups_from_state(state: LayoutState) -> list[dict[str, Any]]:
return groups_from_compose_meta((state.meta or {}).get("compose_views"))
def _membership_maps(
groups: list[dict[str, Any]],
valid: set[str],
) -> tuple[dict[str, list[str]], set[str], dict[str, tuple[float, float]]]:
"""Return node→group_keys, shared portals, group_key→exclusive centroid xy."""
counts: dict[str, int] = {}
g_members: dict[str, list[str]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
g_members[key] = members
for n in members:
counts[n] = counts.get(n, 0) + 1
shared = {n for n, c in counts.items() if c > 1}
# Need positions later — centroids filled by caller.
return (
{n: [k for k, ms in g_members.items() if n in ms] for n in counts},
shared,
{},
)
def _exclusive_centroids(
groups: list[dict[str, Any]],
pos: dict[str, tuple[float, float]],
shared: set[str],
) -> dict[str, tuple[float, float]]:
out: dict[str, tuple[float, float]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in pos]
exclusive = [n for n in members if n not in shared]
use = exclusive if len(exclusive) >= 1 else members
if not use:
continue
cx = sum(pos[n][0] for n in use) / len(use)
cy = sum(pos[n][1] for n in use) / len(use)
out[key] = (cx, cy)
return out
def _anchor_for_node(
nid: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
*,
node_groups: dict[str, list[str]],
centroids: dict[str, tuple[float, float]],
global_cx: float,
global_cy: float,
) -> tuple[float, float, str]:
nbs = [pos[v] for v in adj.get(nid, ()) if v in pos]
if nbs:
ax = sum(p[0] for p in nbs) / len(nbs)
ay = sum(p[1] for p in nbs) / len(nbs)
return ax, ay, "neighbors"
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
return cx, cy, "unit"
return global_cx, global_cy, "global"
def _inward_samples(
x0: float,
y0: float,
ax: float,
ay: float,
*,
max_pull: float,
) -> list[tuple[float, float, float, float]]:
"""Samples on ray toward anchor; never past the anchor; capped by max_pull."""
dx, dy = ax - x0, ay - y0
dist = math.hypot(dx, dy)
if dist < 8.0:
return []
ux, uy = dx / dist, dy / dist
px, py = -uy, ux
out: list[tuple[float, float, float, float]] = []
base_ang = math.degrees(math.atan2(uy, ux)) % 360.0
for t in _PULL_FRACS:
pull = min(dist * t, max_pull)
if pull < 12.0:
continue
# Stay short of the anchor (leave 4px slack).
pull = min(pull, dist - 4.0)
if pull < 12.0:
continue
for dang in _ANGLE_JITTERS:
if dang == 0.0:
nx = x0 + ux * pull
ny = y0 + uy * pull
ang = base_ang
else:
rad = math.radians(base_ang + dang)
c, s = math.cos(rad), math.sin(rad)
nx = x0 + c * pull
ny = y0 + s * pull
# Reject if farther from anchor than start.
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
ang = (base_ang + dang) % 360.0
r = math.hypot(nx - x0, ny - y0)
if r < 12.0 or r > max_pull + 1:
continue
out.append((nx, ny, r, ang))
# Mild perpendicular nudges at mid pull (still closer to anchor).
mid = min(dist * 0.35, max_pull)
if mid >= 20.0:
for sign in (-1.0, 1.0):
nx = x0 + ux * mid + px * sign * mid * 0.25
ny = y0 + uy * mid + py * sign * mid * 0.25
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
r = math.hypot(nx - x0, ny - y0)
if 12.0 <= r <= max_pull + 1:
ang = math.degrees(math.atan2(ny - y0, nx - x0)) % 360.0
out.append((nx, ny, r, ang))
return out
def _densify_score_key(c: dict[str, Any]) -> tuple:
# Crossings first; then shorter incident stretch; prefer larger inward pull.
return (
int(c["crossings"]["global"]),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
-float(c.get("r") or 0.0),
)
def densify_sweep_node(
state: LayoutState,
node_id: str,
*,
params: LayoutParams | None = None,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
cand_cap: int = 180,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
top_k: int = 3,
groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Inward pull candidates for one node; return diversified top-k.
Default ``protect_rigid=off`` so multi-round densify may move portals;
other layout actions keep portal freeze. Pass ``protect_rigid=portals``
to opt back into rigid protection.
"""
params = params or LayoutParams()
st = state
nid = str(node_id).strip()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
if nid not in pos:
return {"ok": False, "error": "node_not_on_view", "node_id": nid}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
if nid in frozen:
return {
"ok": False,
"error": "frozen",
"node_id": nid,
"hint": "portal/rigid frozen; protect_rigid=off or pick a corridor node",
}
pull = float(max_pull if max_pull is not None else _MAX_PULL)
pull = max(80.0, min(pull, _MAX_PULL_CAP))
nbs_cap = pull * 2.5
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
# With groups + protect on: shared portals stay frozen.
if groups and nid in shared and not _protect_is_off(protect_rigid):
return {
"ok": False,
"error": "shared_portal",
"node_id": nid,
"hint": "shared portals frozen; densify defaults protect_rigid=off",
}
centroids = _exclusive_centroids(groups, pos, shared)
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
ax, ay, anchor_kind = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
stretch0 = _incident_stretch(nid, pos, adj, target_nn)
samples = _inward_samples(x0, y0, ax, ay, max_pull=pull)
# Also try unit centroid if neighbor anchor was used.
if anchor_kind == "neighbors":
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
samples.extend(_inward_samples(x0, y0, cx, cy, max_pull=pull))
seen: set[tuple[int, int]] = set()
uniq: list[tuple[float, float, float, float]] = []
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
uniq.append((sx, sy, r, ang))
if len(uniq) >= cand_cap:
break
scored: list[dict[str, Any]] = []
for sx, sy, r, ang in uniq:
c = _eval_candidate(
nid,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=nn_floor,
nbs_cap=nbs_cap,
)
if c is None:
continue
# Densify: reject moves that raise global crossings.
if int(c["delta"]["global"]) > 0:
continue
scored.append(c)
scored.sort(key=_densify_score_key)
# Prefer stretch drop among non-rising-x candidates.
improving = [
c
for c in scored
if c["delta"]["global"] <= 0 and float(c.get("stretch") or 99) < stretch0 - 1e-4
]
pool = improving if improving else scored
top = _diversify_top(pool, k=max(1, int(top_k)), min_angle_sep=min_angle_sep)
return {
"ok": True,
"node_id": nid,
"name": names.get(nid, nid),
"x0": round(x0, 1),
"y0": round(y0, 1),
"degree": len(adj.get(nid, ())),
"anchor": {"x": round(ax, 1), "y": round(ay, 1), "kind": anchor_kind},
"stretch_before": round(stretch0, 3),
"crossings_before": {"global": int(global0), "incident": int(local0)},
"candidates": top,
"sampled": len(seen),
"improving_n": len(improving),
"max_pull": pull,
"nn_floor": nn_floor,
"hint": (
"prefer rank1 (inward, x not up, stretch down); pick 2/3 if label/util. "
"apply with params.pick=1|2|3."
),
}
def apply_densify_pick(
state: LayoutState,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
st = state.copy()
nid = str(sweep.get("node_id") or "")
cands = list(sweep.get("candidates") or [])
if not nid or nid not in st.positions or not cands:
return OpResult(
state=st,
moved=set(),
op="densify_sweep",
note="densify_sweep:noop",
params={"error": "no_candidates"},
)
idx = max(1, min(int(pick), len(cands))) - 1
chosen = cands[idx]
st.positions[nid] = (float(chosen["x"]), float(chosen["y"]))
st.last_moved = {nid}
st.meta["densify_sweep"] = {
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
}
return OpResult(
state=st,
moved={nid},
op="densify_sweep",
params={
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
},
note=(
f"densify_sweep pick={idx + 1} "
f"g{sweep.get('crossings_before', {}).get('global')}->"
f"{chosen['crossings']['global']}"
),
)
def densify_sweep_round(
state: LayoutState,
*,
params: LayoutParams | None = None,
top_n: int = 16,
max_degree: int = 8,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
groups: list[dict[str, Any]] | None = None,
) -> OpResult:
"""Scan sparse/high-stretch nodes; auto-apply #1 if x not up and stretch drops.
Default ``protect_rigid=off`` (may move portals). Opt in with portals/all.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
centroids = _exclusive_centroids(groups, pos, shared)
if groups and not _protect_is_off(protect_rigid):
frozen |= shared
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
global0 = count_edge_crossings(pos, links)
focus = {str(x) for x in (focus_ids or []) if str(x)}
scored_nodes: list[tuple[float, str]] = []
for nid in pos:
if nid in frozen:
continue
deg = len(adj.get(nid, ()))
if deg >= max_degree or deg == 0:
continue
if groups and nid not in node_groups and not focus:
continue
stretch = _incident_stretch(nid, pos, adj, target_nn)
ax, ay, _ = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
dist = math.hypot(pos[nid][0] - ax, pos[nid][1] - ay)
if stretch < 1.15 and dist < 180 and nid not in focus:
continue
# Higher stretch / farther from anchor first; focus boost.
pri = 0.0 if nid in focus else 1.0
scored_nodes.append((-stretch * 10 - dist / 500.0 + pri * 100, nid))
scored_nodes.sort()
movable = [nid for _, nid in scored_nodes[: max(1, int(top_n))]]
moved: set[str] = set()
trace: list[dict[str, Any]] = []
for nid in movable:
st.positions = pos
sweep = densify_sweep_node(
st,
nid,
params=params,
max_pull=max_pull,
nn_floor=nn_floor,
min_angle_sep=min_angle_sep,
protect_rigid="off",
frozen_ids=frozen,
groups=groups,
)
if not sweep.get("ok"):
trace.append({"node_id": nid, "skipped": sweep.get("error")})
continue
cands = list(sweep.get("candidates") or [])
if not cands:
trace.append({"node_id": nid, "skipped": "no_candidates"})
continue
best = cands[0]
stretch_b = float(sweep.get("stretch_before") or 0)
stretch_a = float(best.get("stretch") or 0)
if int(best["delta"]["global"]) > 0:
trace.append(
{
"node_id": nid,
"skipped": "crossing_up",
"best_delta": best["delta"],
}
)
continue
if stretch_a >= stretch_b - 1e-4:
# Allow pure global drop even if stretch flat.
if int(best["delta"]["global"]) >= 0:
trace.append(
{
"node_id": nid,
"skipped": "no_stretch_gain",
"stretch_before": stretch_b,
"stretch_after": stretch_a,
}
)
continue
pos[nid] = (float(best["x"]), float(best["y"]))
moved.add(nid)
trace.append(
{
"node_id": nid,
"name": names.get(nid, nid),
"applied": True,
"pick": 1,
"xy": [best["x"], best["y"]],
"delta": best["delta"],
"stretch": {"before": stretch_b, "after": stretch_a},
}
)
st.positions = pos
st.last_moved = moved
end_g = count_edge_crossings(pos, links)
meta = {
"start_crossings": global0,
"end_crossings": end_g,
"moved_n": len(moved),
"scanned_n": len(movable),
"trace": trace,
"top_n": top_n,
"max_degree": max_degree,
"nn_floor": nn_floor,
}
st.meta["densify_sweep"] = meta
return OpResult(
state=st,
moved=moved,
op="densify_sweep_round",
params=meta,
note=f"densify_sweep_round {global0}->{end_g} moved={len(moved)}/{len(movable)}",
)
def densify_corridor_scan(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
corridor_caps: list[float] | None = None,
pulls: list[float] | None = None,
iters: int = 6,
x_slack: int | None = None,
) -> OpResult:
"""Sweep corridor_cap×pull; keep best util with ov=0 and tight crossing slack."""
st0 = state.copy()
groups = groups if groups is not None else _groups_from_state(st0)
before = score_state(st0)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = int((before.get("summary") or {}).get("overlaps") or 0)
if before_ov == 0:
before_ov = len(overlapping_nodes(st0))
xs = [p[0] for p in st0.positions.values()]
ys = [p[1] for p in st0.positions.values()]
area0 = max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
caps = list(corridor_caps) if corridor_caps else list(_DEFAULT_CORRIDOR_CAPS)
pull_list = list(pulls) if pulls else list(_DEFAULT_PULLS)
slack = (
max(5, int(before_x * 0.05))
if x_slack is None
else max(0, int(x_slack))
)
trials: list[dict[str, Any]] = []
best: dict[str, Any] | None = None
for cap in caps:
for pull in pull_list:
op = shrink_long_corridors(
st0,
edge_len_cap=float(cap),
pull=float(pull),
iters=max(1, int(iters)),
max_bridges=8 if groups else 12,
min_island=2 if groups else 6,
groups=groups or None,
accept_crossings=True,
)
fin = score_state(op.state)
util = float((fin.get("summary") or {}).get("util") or 0.0)
x1 = int((fin.get("summary") or {}).get("crossings") or 0)
ov = len(overlapping_nodes(op.state))
xs1 = [p[0] for p in op.state.positions.values()]
ys1 = [p[1] for p in op.state.positions.values()]
area1 = max(max(xs1) - min(xs1), 1e-6) * max(max(ys1) - min(ys1), 1e-6)
area_ratio = area0 / area1
row = {
"corridor_cap": float(cap),
"pull": float(pull),
"util": util,
"crossings": x1,
"overlaps": ov,
"moved_n": len(op.moved),
"bbox_area_ratio": round(area_ratio, 4),
"note": op.note,
}
trials.append(row)
if ov > 0:
continue
if x1 > before_x + slack:
continue
# Accept if util rises at all, or bbox shrinks ≥0.8% (metro
# corridors often move area×1.01 before util clears 1%).
util_up = util > before_util + 1e-6
area_up = area_ratio >= 1.008
if not util_up and not area_up:
continue
rank = (util, area_ratio, -x1)
if best is None or rank > best["rank"]:
best = {
"rank": rank,
"state": op.state,
"moved": set(op.moved),
"row": row,
}
if best is None:
meta = {
"start_crossings": before_x,
"end_crossings": before_x,
"start_util": before_util,
"end_util": before_util,
"reverted": True,
"reason": "no_util_gain",
"trials": trials,
"x_slack": slack,
}
st0.meta["densify_corridor"] = meta
return OpResult(
state=st0,
moved=set(),
op="densify_corridor_scan",
params=meta,
note="densify_corridor_scan:reverted no_util_gain",
)
st = best["state"]
fin = score_state(st)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
meta = {
"start_crossings": before_x,
"end_crossings": end_x,
"start_util": before_util,
"end_util": end_util,
"reverted": False,
"chosen": best["row"],
"trials": trials,
"x_slack": slack,
"moved_n": len(best["moved"]),
}
st.meta = dict(st.meta or {})
st.meta["densify_corridor"] = meta
st.last_moved = best["moved"]
return OpResult(
state=st,
moved=best["moved"],
op="densify_corridor_scan",
params=meta,
note=(
f"densify_corridor_scan util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} "
f"cap={best['row']['corridor_cap']:.0f} pull={best['row']['pull']}"
),
)
def densify_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("node_id") is not None:
out["node_id"] = str(o.get("node_id") or "").strip()
elif o.get("fabric_node_id") is not None:
out["node_id"] = str(o.get("fabric_node_id") or "").strip()
if o.get("pick") is not None:
try:
out["pick"] = max(1, min(3, int(o["pick"])))
except (TypeError, ValueError):
out["pick"] = 1
for flag in ("round", "corridor"):
if o.get(flag) is not None:
out[flag] = str(o.get(flag)).lower() not in {
"0",
"false",
"no",
"off",
"",
} or o.get(flag) is True
phase = str(o.get("phase") or "").strip().lower()
if phase in {"corridor", "corridors", "shrink"}:
out["corridor"] = True
elif phase in {"intra", "node", "round"}:
out["round"] = out.get("round", True)
for key, cast, default in (
("top_n", int, 16),
("max_degree", int, 8),
("cand_cap", int, 180),
("top_k", int, 3),
("iters", int, 6),
):
if key not in o or o[key] is None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
out[key] = default
if o.get("max_pull") is not None:
try:
out["max_pull"] = float(o["max_pull"])
except (TypeError, ValueError):
pass
if o.get("nn_floor") is not None:
try:
out["nn_floor"] = float(o["nn_floor"])
except (TypeError, ValueError):
out["nn_floor"] = 60.0
else:
out["nn_floor"] = 60.0
if o.get("min_angle_sep") is not None:
try:
out["min_angle_sep"] = float(o["min_angle_sep"])
except (TypeError, ValueError):
out["min_angle_sep"] = 35.0
else:
out["min_angle_sep"] = 35.0
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
else:
# densify breaks rigid by default (opt in with protect_rigid=portals).
out["protect_rigid"] = "off"
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
raw_p = o.get("portal_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
caps = o.get("corridor_caps") or o.get("corridor_cap_list")
if isinstance(caps, list) and caps:
try:
out["corridor_caps"] = [float(x) for x in caps]
except (TypeError, ValueError):
pass
elif o.get("corridor_cap") is not None:
try:
out["corridor_caps"] = [float(o["corridor_cap"])]
except (TypeError, ValueError):
pass
pulls = o.get("pulls") or o.get("pull_list")
if isinstance(pulls, list) and pulls:
try:
out["pulls"] = [float(x) for x in pulls]
except (TypeError, ValueError):
pass
elif o.get("pull") is not None:
try:
out["pulls"] = [float(o["pull"])]
except (TypeError, ValueError):
pass
if o.get("x_slack") is not None:
try:
out["x_slack"] = int(o["x_slack"])
except (TypeError, ValueError):
pass
return out