Backend API Routing¶
Problem Statement¶
Route the network from end-users to first and second-degree internal servers, minimizing latency while maintaining low cost and a reliability SLA.
Full example scenario
A backend engineer is tasked with optimizing the configuration and routing rules of an API gateway to ensure efficient handling of requests, reduce latency, and improve the reliability of backend services. The gateway routes incoming requests to five backend services (A, B, C, D, E), each with its own average processing time, reliability rate, and cost per request; from there, requests are routed on to a second layer of internal gateways (F through N), over a set of specific valid routes, each with its own additional processing time and additional reliability. Which percentage of traffic should go to each backend service, and from there to each internal gateway, in order to minimize total latency?
The weighted reliability of the backend services must be at least 99.5%.
The weighted reliability of the backend-to-gateway routes must be at least 99.5%.
The routing percentages into the backend services must sum to 100%, with no single service taking more than 40%.
For each backend service, its outgoing routes to internal gateways must sum to 100%, with no single route taking more than 75%.
The average cost per request across the whole system must not exceed $0.40.
The backend services data can be found in
network_routing_backend.csv, with the columnsService,Average Processing Time (ms),Reliability (percentage),Cost per Request ($).The valid routes from backend services to internal gateways can be found in
network_routing_internal.csv, with the columnsBackend Service,Internal Gateway,Additional Processing Time (ms),Additional Reliability (percentage).
What an optimal solution looks like
Entry traffic → backend services → internal gateways.
Entry gateway
100% of traffic
Service A
0% · 200ms · 99.9%
Service B
36.67% · 150ms · 99.6%
Service C
23.33% · 100ms · 99.0%
Service D
40.00% · 120ms · 99.7%
Service E
0% · 180ms · 99.8%
Gateway F
0%
Gateway G
0%
Gateway H
27.50% of traffic
Gateway I
9.17% of traffic
Gateway J
0%
Gateway K
17.50% of traffic
Gateway L
5.83% of traffic
Gateway M
10.00% of traffic
Gateway N
30.00% of traffic
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same API gateway routing problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
We run an API gateway that routes incoming requests to a set of backend services, and from there some of the traffic gets routed on to a second layer of internal gateways. We want to decide what percentage of traffic to send where, to minimize overall latency, while keeping things reliable and within budget. Can you help me build a model for this?
This is a two-layer traffic routing problem. Before anything else I need the topology: how many entry points there are, and whether every backend service can reach every internal gateway or only some pairs are valid — that shapes which decision variables even exist.
Great topic, Orhan! This is a well-suited problem for mathematical optimization. Let me ask you a few focused questions to build out a complete and accurate specification.
Q1: Can you describe the two-layer network structure in a bit more detail?
Layer 1 — Is there a single entry-point API gateway, or could there be multiple?
Layer 2 — Is the routing from backend services to internal gateways always a possibility, or only for certain service-gateway pairs?
There’s a single entry-point gateway. It splits traffic across 5 backend services (A, B, C, D, E), each with its own average processing time, reliability rate, and cost per request. From there, only specific (backend service, internal gateway) pairs are valid routes — not every service can reach every gateway. We want to minimize total expected latency, subject to: weighted backend reliability at least 99.5%, weighted route reliability at least 99.5%, backend allocations summing to 100% with no single service over 40%, each service’s outgoing routes summing to 100% with no single route over 75%, and average cost per request at most $0.40.
One more thing - can we also make sure no single internal gateway ends up receiving more than 60% of all traffic overall, since some of our internal gateways have limited capacity? We’re worried about overloading one of them even if the per-route percentages look fine individually.
Topology, five constraints and a sixth volunteered gateway-load cap are all recorded now. What’s still open is exactly how “total latency” should be computed across two hops — that has to be pinned down precisely before an objective function can be written.
Good, the spec is taking shape. Now I need to understand exactly how latency is calculated so the objective is precise.
Q2: How should total expected latency be computed across the two layers?
Sum of both hops — backend processing time plus the route’s additional time, weighted average across all full paths.
Backend layer only.
Gateway layer only.
Weighted sum with a priority factor.
Option 1 - sum of both hops, weighted average total per-request latency across the full entry->backend->gateway path.
That fixes the objective’s shape, but it raises a second question the user hasn’t specified yet: what exactly does “weighted” mean here? A route’s share of its own service’s traffic is not the same number as its share of all traffic — and the reliability and gateway-load constraints both depend on which one is meant.
One more important clarification before we move to implementation.
Q3: For the backend-to-gateway reliability constraint, how should the weighting work?
Full-path weighting — weight each route’s reliability by its share of total traffic (backend allocation x route allocation).
Within-service weighting — average each service’s routes independently, then average those across services, treating each service equally regardless of traffic volume.
Option 1, full-path weighting - that’s the correct way to think about it.
The specification is now complete and fully consistent with everything you have described.
Objective: Minimize weighted average end-to-end latency per request (backend processing time + route additional time), weighted by full-path traffic share.
Constraints captured: backend allocations sum to 100% and cap at 40%; weighted backend reliability >= 99.5%; weighted average cost <= $0.40; per-service route allocations sum to 100% and cap at 75%; weighted route reliability >= 99.5% (full-path weighted); no internal gateway above 60% of total traffic.
Are you happy with this specification, or would you like to adjust anything before I move on to the model implementation?
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on. Choosing full-path weighting in Q3 is what turns this into a genuinely harder model than it looks: two decision layers multiplied together means the reliability, cost and gateway-load constraints are no longer simple linear sums, a point the Formulation on demand tab returns to.
With the topology, objective and all six constraints pinned down, every entity in the specification is defined in the abstract — but no actual numbers exist yet. Modeler asked for those as two files rather than as rows typed into the chat: one for the backend services, one for the valid routes between backend services and internal gateways. Attach them and it reads the real shape of your network instead of guessing at it — row counts, columns, units and all.
Service,Average Processing Time (ms),Reliability (percentage),Cost per Request ($)
A,200,99.9,0.30
B,150,99.6,0.25
C,100,99.0,0.10
D,120,99.7,0.15
E,180,99.8,0.20
Backend Service,Internal Gateway,Additional Processing Time (ms),Additional Reliability (percentage)
A,F,50,99.8
A,G,60,99.7
A,H,55,99.9
B,H,40,99.6
B,I,45,99.7
C,J,50,99.8
C,K,30,99.5
C,L,35,99.6
D,M,25,99.7
D,N,20,99.8
E,F,60,99.9
E,G,55,99.8
Here are the actual data files. network_routing_backend.csv has one row per backend service with its processing time in ms, reliability as a percentage, and cost per request in dollars. network_routing_internal.csv has one row per valid (backend service, internal gateway) route, with additional processing time in ms and additional reliability as a percentage for that hop. Not every backend service connects to every gateway - only the pairs listed are valid routes.
Five backend service rows, twelve route rows across nine internal gateways — a sparse structure, not the full 5x9 product, which matches the “only listed pairs are valid” framing from the specification. Processing time in ms, reliability and cost in the same units already used throughout the conversation, so nothing needs rescaling. The specification was already complete before this data arrived — every constraint, the objective, and the full-path weighting decision were all settled while pinning down the problem — so there is nothing left to ask before implementing.
All 21 tests pass across all four scenarios. Here is a complete summary of what was built:
The optimizer steers all traffic to services B (36.7%), C (23.3%), D (40%) — the three cheapest and most reliable services — avoiding the slower, more expensive A and E. The backend reliability constraint is exactly binding, confirming the optimizer is trading latency for reliability at the margin.
Quadratic model: The problem is a non-convex Quadratic Program (QP) because the objective and two constraints involve products of two decision variables — the backend allocation times the route allocation within it. Gurobi handles this automatically via its non-convex QP solver (spatial branch-and-bound), which is why the log below shows it solved as a MIP internally.
Because the specification had no open items left, attaching the two files doubled as the go-ahead: the same message asked Modeler to finalize the specification, implement it, write tests, run them, and report back — covered in full in the Cloud solve tab.
Where your files live
Everything in the conversation — the files you attached and every artifact Modeler generates from them — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds seven items:
Attachment |
What it is |
|---|---|
|
The backend services data you attached. |
|
The valid routes data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
An API gateway receives all incoming traffic and must route it across a set of backend services. Some of that traffic is then forwarded from those backend services to a second layer of internal gateways. The goal is to decide what percentage of traffic to send along each path — from the entry gateway to each backend service, and from each backend service to each reachable internal gateway — in order to minimize overall end-to-end latency per request. Decisions must respect reliability guarantees, budget limits, individual service and route capacity caps, and a global internal gateway load cap to prevent overloading.
2. Objective
Minimize the weighted average total end-to-end latency per request across all full paths (entry gateway to backend service to internal gateway). Latency for each path is the sum of the backend service processing time and the additional processing time on the backend-to-gateway route. Each path is weighted by its share of total traffic, which equals the backend service’s traffic allocation multiplied by the route’s allocation within that service.
3. Constraints
C1 — Backend allocation sums to 100%. The traffic percentages assigned to all backend services must sum to exactly 100%. All traffic arriving at the entry gateway must be distributed.
C2 — Individual backend service cap. No single backend service may receive more than 40% of total traffic. This prevents over-reliance on any one service.
C3 — Weighted backend reliability. The weighted average reliability across backend services, weighted by their traffic allocation, must be at least 99.5%. This ensures the backend layer as a whole meets the reliability target.
C4 — Average cost cap. The weighted average cost per request across all backend services, weighted by their traffic allocation, must not exceed $0.40. Costs are only incurred at the backend service layer; backend-to-gateway hops have no cost.
C5 — Per-service route allocations sum to 100%. For each backend service, the traffic percentages across all its valid outgoing routes to internal gateways must sum to exactly 100%. All traffic arriving at a backend service must be forwarded somewhere.
C6 — Individual route cap within a service. No single backend-to-gateway route may carry more than 75% of the traffic flowing through its backend service. This distributes load across gateways.
C7 — Weighted route reliability. The weighted average reliability across all backend-to-gateway routes, weighted by each route’s share of total traffic (backend allocation times route allocation within service), must be at least 99.5%.
C8 — Internal gateway total load cap. No single internal gateway may receive more than 60% of total incoming traffic across all paths, preventing overload of any individual gateway regardless of which backend services feed it.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Backend services |
service, processing time (ms), reliability (%), cost per request ($) |
5 rows: A (200ms, 99.9%, $0.30), B (150ms, 99.6%, $0.25), C (100ms, 99.0%, $0.10), D (120ms, 99.7%, $0.15), E (180ms, 99.8%, $0.20) |
Valid routes |
backend service, internal gateway, additional time (ms), additional reliability (%) |
12 rows across 9 internal gateways (F-N); not every service reaches every gateway |
Global parameters |
max backend %, min backend reliability, max avg cost, max route %, min route reliability, max gateway load |
40%, 99.5%, $0.40, 75%, 99.5%, 60% |
Only 12 of the 45 possible (service, gateway) combinations are valid routes, so the model is sparse by construction rather than filtered down from a dense grid.
5. Output
The backend traffic allocation for every service.
The route allocation for every valid (service, gateway) pair.
The total load reaching every internal gateway.
Achieved weighted backend reliability, weighted route reliability, and weighted average cost per request.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — the data above, default parameters. |
Optimal. 157.25 ms latency, 99.50% backend reliability (binding), 99.66% route reliability, $0.175 average cost, busiest gateway (N) at 30% of the 60% cap. |
T2 |
Average cost cap tightened to $0.15. |
Optimal. Traffic skewed toward the cheapest services (C at $0.10, D at $0.15); at least 60% of traffic on C+D combined. |
T3 |
Backend and route reliability floors both raised to 99.99%. |
Reported as infeasible — no plan is returned. |
T4 |
Gateway load cap tightened to 25%. |
Optimal. No internal gateway exceeds 25% of total traffic; all other constraints still satisfied. |
T5 |
Gateway load cap relaxed to 90% (non-binding — the busiest gateway in T1 sits at only 30%). |
Optimal, identical to T1: same 157.25 ms latency, same backend allocations. Confirms the decision is driven by the reliability floor and Service D’s cap, not by the gateway cap. |
T6 |
Naive baseline: split traffic evenly across all 5 backend services (20% each) and evenly across each service’s valid routes, no optimization at all. |
193.17 ms latency (+35.92 ms, +22.8%) and $0.20 average cost (+$0.025) versus the optimized plan, while still technically meeting the reliability floors. |
7. Out of scope
Multi-objective optimization balancing latency against cost or reliability simultaneously.
Dynamic or time-varying traffic patterns.
Costs on the backend-to-gateway (second hop) routes.
Latency or reliability at the entry gateway itself.
Integer or discrete routing decisions — all allocations are continuous percentages.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version that came out of the run-and-fix loop in the Cloud solve
tab, after Modeler ran the tests and confirmed the results.
"""
API Gateway Routing Optimizer
Minimizes weighted average end-to-end latency (ms) across all full paths
(entry gateway -> backend service -> internal gateway) subject to:
- Traffic allocation constraints at the backend layer
- Traffic allocation constraints at the per-service route layer
- Weighted reliability requirements at both layers
- Average cost-per-request budget
- Individual internal gateway total load cap
"""
import gurobipy as gp
from gurobipy import GRB
import pandas as pd
def compute_naive_baseline(backend_df: pd.DataFrame, routes_df: pd.DataFrame) -> dict:
"""
Compute KPIs for the naive even-split routing plan without running the optimizer.
The naive plan allocates traffic equally across all backend services (1/N each)
and equally across each service's valid routes (1/K_s each, where K_s is the
number of valid routes for service s).
Parameters
----------
backend_df : DataFrame with columns [service, processing_time_ms,
reliability_pct, cost_per_request]
routes_df : DataFrame with columns [backend_service, internal_gateway,
additional_time_ms, additional_reliability_pct]
Returns
-------
dict with keys:
objective_value : Weighted average end-to-end latency (ms)
weighted_backend_reliability : Weighted backend reliability (%)
weighted_route_reliability : Weighted route reliability (%)
weighted_avg_cost : Weighted average cost per request ($)
backend_allocations : dict {service -> % allocation}
route_allocations : dict {(service, gateway) -> % within-service allocation}
"""
services = list(backend_df["service"])
n_services = len(services)
backend_alloc_pct = 100.0 / n_services # equal share per service (%)
backend_alloc = {s: backend_alloc_pct for s in services}
# Equal share per route within each service
route_alloc = {}
for s in services:
gateways = list(routes_df.loc[routes_df["backend_service"] == s, "internal_gateway"])
n_routes = len(gateways)
for g in gateways:
route_alloc[(s, g)] = 100.0 / n_routes # (%)
routes = list(zip(routes_df["backend_service"], routes_df["internal_gateway"]))
proc_time = dict(zip(backend_df["service"], backend_df["processing_time_ms"]))
add_time = dict(zip(routes, routes_df["additional_time_ms"]))
reliability = dict(zip(backend_df["service"], backend_df["reliability_pct"]))
add_rel = dict(zip(routes, routes_df["additional_reliability_pct"]))
cost = dict(zip(backend_df["service"], backend_df["cost_per_request"]))
# Weighted average end-to-end latency
latency = sum(
(proc_time[s] + add_time[s, g])
* backend_alloc[s]
* route_alloc[(s, g)]
/ 10000.0
for s, g in routes
)
# Weighted backend reliability
w_backend_rel = sum(reliability[s] * backend_alloc[s] for s in services) / 100.0
# Full-path weighted route reliability
w_route_rel = sum(
add_rel[s, g] * backend_alloc[s] * route_alloc[(s, g)]
for s, g in routes
) / 10000.0
# Weighted average cost
w_avg_cost = sum(cost[s] * backend_alloc[s] for s in services) / 100.0
return {
"objective_value": latency,
"weighted_backend_reliability": w_backend_rel,
"weighted_route_reliability": w_route_rel,
"weighted_avg_cost": w_avg_cost,
"backend_allocations": backend_alloc,
"route_allocations": route_alloc,
}
class GatewayRoutingOptimizer:
"""
Optimizer for API gateway traffic routing across a two-layer network.
Layer 1: Entry gateway -> Backend services (allocation percentages)
Layer 2: Backend services -> Internal gateways (route percentages per service)
Minimizes weighted average end-to-end latency while satisfying reliability,
cost, allocation, and gateway load constraints.
"""
def __init__(self, env):
"""Initialize optimizer with a Gurobi environment."""
self.env = env
self.model = None
# Data
self.backend_df = None
self.routes_df = None
# Constraint parameters
self.max_backend_pct = 40.0 # Max % any single backend service receives
self.min_backend_reliability = 99.5 # Min weighted backend reliability (%)
self.max_avg_cost = 0.40 # Max weighted average cost per request ($)
self.max_route_pct = 75.0 # Max % any single route within a service
self.min_route_reliability = 99.5 # Min weighted route reliability (%)
self.max_gateway_load = 60.0 # Max % total traffic to any internal gateway
# Decision variables
self._backend_alloc = None # backend_alloc[s]: % of traffic to backend service s
self._route_alloc = None # route_alloc[s, g]: % of service s traffic sent to gateway g
# Derived sets
self._services = None
self._routes = None # list of valid (service, gateway) pairs
def __enter__(self):
"""Enter context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit context manager and dispose model."""
if self.model is not None:
self.model.dispose()
return False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def set_data(
self,
backend_df: pd.DataFrame,
routes_df: pd.DataFrame,
max_backend_pct: float = 40.0,
min_backend_reliability: float = 99.5,
max_avg_cost: float = 0.40,
max_route_pct: float = 75.0,
min_route_reliability: float = 99.5,
max_gateway_load: float = 60.0,
):
"""
Load input data and constraint parameters.
Parameters
----------
backend_df : DataFrame with columns [service, processing_time_ms,
reliability_pct, cost_per_request]
routes_df : DataFrame with columns [backend_service, internal_gateway,
additional_time_ms, additional_reliability_pct]
max_backend_pct : Max % of total traffic for any single backend service
min_backend_reliability : Min weighted backend-layer reliability (%)
max_avg_cost : Max weighted average cost per request ($)
max_route_pct : Max % within-service allocation for any single route
min_route_reliability : Min weighted route-layer reliability (%) full-path weighted
max_gateway_load : Max % of total traffic to any single internal gateway
"""
self.backend_df = backend_df.copy()
self.routes_df = routes_df.copy()
self.max_backend_pct = max_backend_pct
self.min_backend_reliability = min_backend_reliability
self.max_avg_cost = max_avg_cost
self.max_route_pct = max_route_pct
self.min_route_reliability = min_route_reliability
self.max_gateway_load = max_gateway_load
self._services = list(backend_df["service"])
self._routes = list(zip(routes_df["backend_service"], routes_df["internal_gateway"]))
# ------------------------------------------------------------------
# Model building
# ------------------------------------------------------------------
def build_model(self):
"""Create the Gurobi model, variables, constraints, and objective."""
self.model = gp.Model("api_gateway_routing", env=self.env)
self._create_variables()
self._set_objective()
self._add_constraints()
def _create_variables(self):
"""
Declare decision variables.
backend_alloc[s] : Fraction (0-100) of total traffic allocated to backend service s
route_alloc[s, g] : Fraction (0-100) of service s traffic routed to internal gateway g
Only defined for valid (s, g) pairs.
"""
self._backend_alloc = self.model.addVars(
self._services,
lb=0.0, ub=100.0,
name="backend_alloc"
)
self._route_alloc = self.model.addVars(
self._routes,
lb=0.0, ub=100.0,
name="route_alloc"
)
def _set_objective(self):
"""
Minimize weighted average end-to-end latency per request.
For each full path (s -> g):
path_latency(s, g) = processing_time(s) + additional_time(s, g)
path_weight(s, g) = backend_alloc[s] * route_alloc[s, g] / 10000
(divides by 100*100 since both are expressed as percentages)
Objective = sum over (s,g) of path_weight(s,g) * path_latency(s,g)
"""
proc_time = dict(zip(self.backend_df["service"], self.backend_df["processing_time_ms"]))
add_time = dict(zip(self._routes, self.routes_df["additional_time_ms"]))
latency_expr = gp.quicksum(
(proc_time[s] + add_time[s, g])
* self._backend_alloc[s]
* self._route_alloc[s, g]
/ 10000.0
for s, g in self._routes
)
self.model.setObjective(latency_expr, GRB.MINIMIZE)
def _add_constraints(self):
"""Add all business constraints to the model."""
self._c1_backend_sum()
self._c2_backend_cap()
self._c3_backend_reliability()
self._c4_cost_cap()
self._c5_route_sum()
self._c6_route_cap()
self._c7_route_reliability()
self._c8_gateway_load_cap()
def _c1_backend_sum(self):
"""C1: Backend allocations must sum to 100%."""
self.model.addConstr(
gp.quicksum(self._backend_alloc[s] for s in self._services) == 100.0,
name="c1_backend_sum"
)
def _c2_backend_cap(self):
"""C2: No single backend service receives more than max_backend_pct% of traffic."""
self.model.addConstrs(
(self._backend_alloc[s] <= self.max_backend_pct for s in self._services),
name="c2_backend_cap"
)
def _c3_backend_reliability(self):
"""C3: Weighted average backend reliability >= min_backend_reliability%.
sum_s (reliability(s) * backend_alloc[s]) / 100 >= min_backend_reliability
Rearranged: sum_s (reliability(s) * backend_alloc[s]) >= min_backend_reliability * 100
"""
reliability = dict(zip(self.backend_df["service"], self.backend_df["reliability_pct"]))
self.model.addConstr(
gp.quicksum(reliability[s] * self._backend_alloc[s] for s in self._services)
>= self.min_backend_reliability * 100.0,
name="c3_backend_reliability"
)
def _c4_cost_cap(self):
"""C4: Weighted average cost per request <= max_avg_cost.
sum_s (cost(s) * backend_alloc[s]) / 100 <= max_avg_cost
Rearranged: sum_s (cost(s) * backend_alloc[s]) <= max_avg_cost * 100
"""
cost = dict(zip(self.backend_df["service"], self.backend_df["cost_per_request"]))
self.model.addConstr(
gp.quicksum(cost[s] * self._backend_alloc[s] for s in self._services)
<= self.max_avg_cost * 100.0,
name="c4_cost_cap"
)
def _c5_route_sum(self):
"""C5: For each backend service, route allocations must sum to 100%."""
for s in self._services:
gateways = list(
self.routes_df.loc[self.routes_df["backend_service"] == s, "internal_gateway"]
)
self.model.addConstr(
gp.quicksum(self._route_alloc[s, g] for g in gateways) == 100.0,
name=f"c5_route_sum[{s}]"
)
def _c6_route_cap(self):
"""C6: No single route within a service may exceed max_route_pct%."""
self.model.addConstrs(
(self._route_alloc[s, g] <= self.max_route_pct for s, g in self._routes),
name="c6_route_cap"
)
def _c7_route_reliability(self):
"""C7: Full-path weighted average route reliability >= min_route_reliability%.
sum_{s,g} (route_reliability(s,g) * backend_alloc[s] * route_alloc[s,g]) / 10000
>= min_route_reliability
Rearranged:
sum_{s,g} (route_reliability(s,g) * backend_alloc[s] * route_alloc[s,g])
>= min_route_reliability * 10000
Note: this is a quadratic constraint because backend_alloc[s] * route_alloc[s,g]
is a product of two variables.
"""
add_rel = dict(zip(self._routes, self.routes_df["additional_reliability_pct"]))
self.model.addConstr(
gp.quicksum(
add_rel[s, g] * self._backend_alloc[s] * self._route_alloc[s, g]
for s, g in self._routes
) >= self.min_route_reliability * 10000.0,
name="c7_route_reliability"
)
def _c8_gateway_load_cap(self):
"""C8: No single internal gateway receives more than max_gateway_load% of total traffic.
For gateway g, its total load as % of all traffic:
sum_{s: (s,g) valid} backend_alloc[s] * route_alloc[s,g] / 100 <= max_gateway_load
Rearranged:
sum_{s: (s,g) valid} backend_alloc[s] * route_alloc[s,g] <= max_gateway_load * 100
Note: quadratic constraint - product of two variables.
"""
gateways = list(self.routes_df["internal_gateway"].unique())
for g in gateways:
services_for_g = list(
self.routes_df.loc[self.routes_df["internal_gateway"] == g, "backend_service"]
)
self.model.addConstr(
gp.quicksum(
self._backend_alloc[s] * self._route_alloc[s, g]
for s in services_for_g
) <= self.max_gateway_load * 100.0,
name=f"c8_gateway_load[{g}]"
)
# ------------------------------------------------------------------
# Solving
# ------------------------------------------------------------------
def solve(self):
"""Optimize the model and return the Gurobi status code."""
self.model.optimize()
return self.model.Status
# ------------------------------------------------------------------
# Solution retrieval
# ------------------------------------------------------------------
def get_solution(self) -> dict:
"""
Extract and return the optimization solution as a dictionary.
Returns
-------
dict with keys:
status : Gurobi status code
is_optimal : bool
objective_value : float or None
backend_allocations : dict {service -> % allocation}
route_allocations : dict {(service, gateway) -> % within-service allocation}
gateway_loads : dict {gateway -> % of total traffic}
weighted_backend_reliability : float or None (%)
weighted_route_reliability : float or None (%)
weighted_avg_cost : float or None ($)
"""
status = self.model.Status
is_optimal = (status == GRB.OPTIMAL)
if not is_optimal:
return {
"status": status,
"is_optimal": False,
"objective_value": None,
"backend_allocations": {},
"route_allocations": {},
"gateway_loads": {},
"weighted_backend_reliability": None,
"weighted_route_reliability": None,
"weighted_avg_cost": None,
}
# Backend allocations
backend_alloc = {s: self._backend_alloc[s].X for s in self._services}
# Route allocations
route_alloc = {(s, g): self._route_alloc[s, g].X for s, g in self._routes}
# Gateway loads (% of total traffic)
gateways = list(self.routes_df["internal_gateway"].unique())
gateway_loads = {}
for g in gateways:
services_for_g = list(
self.routes_df.loc[self.routes_df["internal_gateway"] == g, "backend_service"]
)
gateway_loads[g] = sum(
backend_alloc[s] * route_alloc[s, g] / 100.0
for s in services_for_g
)
# Achieved KPIs
reliability = dict(zip(self.backend_df["service"], self.backend_df["reliability_pct"]))
w_backend_rel = sum(reliability[s] * backend_alloc[s] for s in self._services) / 100.0
add_rel = dict(zip(self._routes, self.routes_df["additional_reliability_pct"]))
w_route_rel = sum(
add_rel[s, g] * backend_alloc[s] * route_alloc[s, g]
for s, g in self._routes
) / 10000.0
cost = dict(zip(self.backend_df["service"], self.backend_df["cost_per_request"]))
w_avg_cost = sum(cost[s] * backend_alloc[s] for s in self._services) / 100.0
return {
"status": status,
"is_optimal": True,
"objective_value": self.model.ObjVal,
"backend_allocations": backend_alloc,
"route_allocations": route_alloc,
"gateway_loads": gateway_loads,
"weighted_backend_reliability": w_backend_rel,
"weighted_route_reliability": w_route_rel,
"weighted_avg_cost": w_avg_cost,
}
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def display_results(self):
"""Print a formatted summary of the optimization solution."""
sol = self.get_solution()
print("=" * 60)
print("API GATEWAY ROUTING OPTIMIZATION RESULTS")
print("=" * 60)
if not sol["is_optimal"]:
print(f"No optimal solution found. Gurobi status: {sol['status']}")
return
print(f"\nMinimized Avg End-to-End Latency : {sol['objective_value']:.2f} ms")
print(f"Weighted Backend Reliability : {sol['weighted_backend_reliability']:.4f}%")
print(f"Weighted Route Reliability : {sol['weighted_route_reliability']:.4f}%")
print(f"Weighted Avg Cost per Request : ${sol['weighted_avg_cost']:.4f}")
print("\n--- Backend Service Allocations ---")
for s, pct in sorted(sol["backend_allocations"].items()):
print(f" Service {s}: {pct:.2f}%")
print("\n--- Route Allocations (within each service) ---")
for (s, g), pct in sorted(sol["route_allocations"].items()):
print(f" {s} -> {g}: {pct:.2f}%")
print("\n--- Internal Gateway Total Load ---")
for g, load in sorted(sol["gateway_loads"].items()):
print(f" Gateway {g}: {load:.2f}% of total traffic")
print("=" * 60)
# ----------------------------------------------------------------------
# Main entry point with full dataset
# ----------------------------------------------------------------------
if __name__ == "__main__":
# Backend services data
backend_df = pd.DataFrame({
"service": ["A", "B", "C", "D", "E"],
"processing_time_ms": [200, 150, 100, 120, 180],
"reliability_pct": [99.9, 99.6, 99.0, 99.7, 99.8],
"cost_per_request": [0.30, 0.25, 0.10, 0.15, 0.20],
})
# Valid routes data
routes_df = pd.DataFrame({
"backend_service": ["A","A","A","B","B","C","C","C","D","D","E","E"],
"internal_gateway": ["F","G","H","H","I","J","K","L","M","N","F","G"],
"additional_time_ms": [ 50, 60, 55, 40, 45, 50, 30, 35, 25, 20, 60, 55],
"additional_reliability_pct":[99.8,99.7,99.9,99.6,99.7,99.8,99.5,99.6,99.7,99.8,99.9,99.8],
})
# --- Optimized solution ---
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 1)
env.setParam("TimeLimit", 10)
env.start()
with GatewayRoutingOptimizer(env) as optimizer:
optimizer.set_data(backend_df, routes_df)
optimizer.build_model()
optimizer.solve()
optimizer.display_results()
# --- Naive baseline comparison ---
print("\n" + "=" * 60)
print("NAIVE EVEN-SPLIT BASELINE")
print("=" * 60)
baseline = compute_naive_baseline(backend_df, routes_df)
print(f"\nNaive Avg End-to-End Latency : {baseline['objective_value']:.2f} ms")
print(f"Naive Backend Reliability : {baseline['weighted_backend_reliability']:.4f}%")
print(f"Naive Route Reliability : {baseline['weighted_route_reliability']:.4f}%")
print(f"Naive Avg Cost per Request : ${baseline['weighted_avg_cost']:.4f}")
print("\n--- Naive Backend Allocations ---")
for s, pct in sorted(baseline["backend_allocations"].items()):
print(f" Service {s}: {pct:.2f}%")
print("\n--- Naive Route Allocations (within each service) ---")
for (s, g), pct in sorted(baseline["route_allocations"].items()):
print(f" {s} -> {g}: {pct:.2f}%")
print("=" * 60)
Alongside the implementation, Modeler writes a pytest suite with one test
class per scenario in the specification, one test method per acceptance
criterion. Each test references its scenario identifier, using the same small
hard-coded backend and route data and a short solver time limit so the suite
runs in well under a second.
"""
Unit tests for the API Gateway Routing Optimizer.
Tests cover all scenarios defined in the specification:
T1: Basic optimization with full dataset
T2: Binding cost constraint (tight budget)
T3: Infeasible reliability requirement
T4: Binding gateway load cap
T5: Robustness to non-binding perturbation (relaxed gateway cap)
T6: Naive even-split baseline comparison
"""
import time
import pytest
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
from network_routing_modeler import GatewayRoutingOptimizer, compute_naive_baseline
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def backend_df():
"""Standard backend services dataset."""
return pd.DataFrame({
"service": ["A", "B", "C", "D", "E"],
"processing_time_ms": [200, 150, 100, 120, 180],
"reliability_pct": [99.9, 99.6, 99.0, 99.7, 99.8],
"cost_per_request": [0.30, 0.25, 0.10, 0.15, 0.20],
})
@pytest.fixture
def routes_df():
"""Standard valid routes dataset."""
return pd.DataFrame({
"backend_service": ["A","A","A","B","B","C","C","C","D","D","E","E"],
"internal_gateway": ["F","G","H","H","I","J","K","L","M","N","F","G"],
"additional_time_ms": [ 50, 60, 55, 40, 45, 50, 30, 35, 25, 20, 60, 55],
"additional_reliability_pct":[99.8,99.7,99.9,99.6,99.7,99.8,99.5,99.6,99.7,99.8,99.9,99.8],
})
def build_and_solve(backend_df, routes_df, **kwargs):
"""Helper: create env, build model, solve, return solution dict."""
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", 10)
env.start()
with GatewayRoutingOptimizer(env) as optimizer:
optimizer.set_data(backend_df, routes_df, **kwargs)
optimizer.build_model()
optimizer.solve()
return optimizer.get_solution()
# ---------------------------------------------------------------------------
# T1: Basic Optimization
# ---------------------------------------------------------------------------
class TestT1BasicOptimization:
"""T1: Full dataset — verify optimal solution satisfies all constraints."""
def test_is_optimal(self, backend_df, routes_df):
"""T1: Solution must be optimal."""
start = time.time()
sol = build_and_solve(backend_df, routes_df)
assert time.time() - start < 15, "Test exceeded time limit"
assert sol["is_optimal"] is True
def test_objective_is_positive(self, backend_df, routes_df):
"""T1: Latency objective must be a positive finite value."""
sol = build_and_solve(backend_df, routes_df)
assert sol["objective_value"] is not None
assert sol["objective_value"] > 0
def test_backend_allocations_sum_to_100(self, backend_df, routes_df):
"""T1: Backend allocations must sum to 100%."""
sol = build_and_solve(backend_df, routes_df)
total = sum(sol["backend_allocations"].values())
assert abs(total - 100.0) < 1e-4
def test_no_backend_exceeds_40pct(self, backend_df, routes_df):
"""T1: No single backend service may exceed 40%."""
sol = build_and_solve(backend_df, routes_df)
for s, pct in sol["backend_allocations"].items():
assert pct <= 40.0 + 1e-4, f"Service {s} exceeds 40%: {pct:.4f}%"
def test_route_allocations_sum_to_100_per_service(self, backend_df, routes_df):
"""T1: Route allocations within each service must sum to 100%."""
sol = build_and_solve(backend_df, routes_df)
services = backend_df["service"].tolist()
for s in services:
routes_for_s = {(bs, g): v for (bs, g), v in sol["route_allocations"].items() if bs == s}
total = sum(routes_for_s.values())
assert abs(total - 100.0) < 1e-4, f"Service {s} routes sum to {total:.4f}%"
def test_no_route_exceeds_75pct(self, backend_df, routes_df):
"""T1: No single within-service route may exceed 75%."""
sol = build_and_solve(backend_df, routes_df)
for (s, g), pct in sol["route_allocations"].items():
assert pct <= 75.0 + 1e-4, f"Route ({s},{g}) exceeds 75%: {pct:.4f}%"
def test_backend_reliability_at_least_99_5(self, backend_df, routes_df):
"""T1: Weighted backend reliability must be >= 99.5%."""
sol = build_and_solve(backend_df, routes_df)
assert sol["weighted_backend_reliability"] >= 99.5 - 1e-4
def test_route_reliability_at_least_99_5(self, backend_df, routes_df):
"""T1: Weighted route reliability must be >= 99.5%."""
sol = build_and_solve(backend_df, routes_df)
assert sol["weighted_route_reliability"] >= 99.5 - 1e-4
def test_avg_cost_at_most_0_40(self, backend_df, routes_df):
"""T1: Weighted average cost must not exceed $0.40."""
sol = build_and_solve(backend_df, routes_df)
assert sol["weighted_avg_cost"] <= 0.40 + 1e-4
def test_no_gateway_exceeds_60pct(self, backend_df, routes_df):
"""T1: No internal gateway may receive more than 60% of total traffic."""
sol = build_and_solve(backend_df, routes_df)
for g, load in sol["gateway_loads"].items():
assert load <= 60.0 + 1e-4, f"Gateway {g} load {load:.4f}% exceeds 60%"
def test_solution_keys_present(self, backend_df, routes_df):
"""T1: Solution dict must contain all expected keys."""
sol = build_and_solve(backend_df, routes_df)
expected_keys = {
"status", "is_optimal", "objective_value",
"backend_allocations", "route_allocations", "gateway_loads",
"weighted_backend_reliability", "weighted_route_reliability", "weighted_avg_cost",
}
assert expected_keys.issubset(sol.keys())
# ---------------------------------------------------------------------------
# T2: Binding Cost Constraint
# ---------------------------------------------------------------------------
class TestT2BindingCostConstraint:
"""T2: Tight cost cap ($0.15) forces traffic toward cheaper services."""
def test_is_optimal_with_tight_cost(self, backend_df, routes_df):
"""T2: Solution must still be optimal under tighter cost cap."""
start = time.time()
sol = build_and_solve(backend_df, routes_df, max_avg_cost=0.15)
assert time.time() - start < 15, "Test exceeded time limit"
assert sol["is_optimal"] is True
def test_cost_respects_tight_cap(self, backend_df, routes_df):
"""T2: Weighted average cost must be <= $0.15."""
sol = build_and_solve(backend_df, routes_df, max_avg_cost=0.15)
assert sol["weighted_avg_cost"] <= 0.15 + 1e-4
def test_low_cost_services_receive_more_traffic(self, backend_df, routes_df):
"""T2: Services C ($0.10) and D ($0.15) should receive majority of traffic."""
sol = build_and_solve(backend_df, routes_df, max_avg_cost=0.15)
alloc = sol["backend_allocations"]
low_cost_share = alloc.get("C", 0) + alloc.get("D", 0)
assert low_cost_share >= 60.0 - 1e-4, (
f"Low-cost services C+D share only {low_cost_share:.2f}% under tight cost cap"
)
def test_all_constraints_still_satisfied(self, backend_df, routes_df):
"""T2: All remaining constraints must still be satisfied under tight cost cap."""
sol = build_and_solve(backend_df, routes_df, max_avg_cost=0.15)
assert abs(sum(sol["backend_allocations"].values()) - 100.0) < 1e-4
assert sol["weighted_backend_reliability"] >= 99.5 - 1e-4
for g, load in sol["gateway_loads"].items():
assert load <= 60.0 + 1e-4
# ---------------------------------------------------------------------------
# T3: Infeasible Reliability Constraint
# ---------------------------------------------------------------------------
class TestT3InfeasibleReliability:
"""T3: Unreachably high reliability requirements make the problem infeasible."""
def test_not_optimal_when_reliability_infeasible(self, backend_df, routes_df):
"""T3: Model must report non-optimal when reliability targets cannot be met."""
start = time.time()
sol = build_and_solve(
backend_df, routes_df,
min_backend_reliability=99.99,
min_route_reliability=99.99
)
assert time.time() - start < 15, "Test exceeded time limit"
assert sol["is_optimal"] is False
def test_no_objective_when_infeasible(self, backend_df, routes_df):
"""T3: Objective value must be None when no feasible solution exists."""
sol = build_and_solve(
backend_df, routes_df,
min_backend_reliability=99.99,
min_route_reliability=99.99
)
assert sol["objective_value"] is None
def test_empty_allocations_when_infeasible(self, backend_df, routes_df):
"""T3: Allocation dicts must be empty when no feasible solution exists."""
sol = build_and_solve(
backend_df, routes_df,
min_backend_reliability=99.99,
min_route_reliability=99.99
)
assert sol["backend_allocations"] == {}
assert sol["route_allocations"] == {}
# ---------------------------------------------------------------------------
# T4: Binding Gateway Load Cap
# ---------------------------------------------------------------------------
class TestT4BindingGatewayLoadCap:
"""T4: Tight gateway load cap (25%) forces redistribution across gateways."""
def test_is_optimal_with_tight_gateway_cap(self, backend_df, routes_df):
"""T4: Solution must be optimal with tight gateway load cap."""
start = time.time()
sol = build_and_solve(backend_df, routes_df, max_gateway_load=25.0)
assert time.time() - start < 15, "Test exceeded time limit"
assert sol["is_optimal"] is True
def test_no_gateway_exceeds_25pct(self, backend_df, routes_df):
"""T4: No internal gateway should receive more than 25% of total traffic."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=25.0)
for g, load in sol["gateway_loads"].items():
assert load <= 25.0 + 1e-4, f"Gateway {g} load {load:.4f}% exceeds 25%"
def test_all_constraints_still_satisfied_tight_gateway(self, backend_df, routes_df):
"""T4: All other constraints must still be satisfied under tight gateway cap."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=25.0)
assert abs(sum(sol["backend_allocations"].values()) - 100.0) < 1e-4
assert sol["weighted_backend_reliability"] >= 99.5 - 1e-4
assert sol["weighted_route_reliability"] >= 99.5 - 1e-4
assert sol["weighted_avg_cost"] <= 0.40 + 1e-4
for s, pct in sol["backend_allocations"].items():
assert pct <= 40.0 + 1e-4
# ---------------------------------------------------------------------------
# T5: Robustness to Non-Binding Perturbation
# ---------------------------------------------------------------------------
class TestT5RobustnessNonBindingPerturbation:
"""
T5: Relaxing the gateway load cap from 60% to 90% should not change the
optimal solution, since the highest gateway load in T1 was only ~30%.
This confirms the optimizer's decisions are driven by the binding constraints
and not by incidental parameter values.
"""
def test_is_optimal_with_relaxed_gateway_cap(self, backend_df, routes_df):
"""T5: Solution must be optimal with relaxed gateway cap (90%)."""
start = time.time()
sol = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
assert time.time() - start < 15, "Test exceeded time limit"
assert sol["is_optimal"] is True
def test_objective_matches_t1(self, backend_df, routes_df):
"""T5: Objective value must match T1 within 0.01 ms — cap was non-binding."""
sol_t1 = build_and_solve(backend_df, routes_df, max_gateway_load=60.0)
sol_t5 = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
assert abs(sol_t5["objective_value"] - sol_t1["objective_value"]) < 0.01, (
f"T5 latency {sol_t5['objective_value']:.4f} ms differs from "
f"T1 latency {sol_t1['objective_value']:.4f} ms by more than 0.01 ms"
)
def test_backend_allocations_match_t1(self, backend_df, routes_df):
"""T5: Backend allocations must match T1 values — relaxed cap changes nothing."""
sol_t1 = build_and_solve(backend_df, routes_df, max_gateway_load=60.0)
sol_t5 = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
for s in backend_df["service"]:
diff = abs(sol_t5["backend_allocations"][s] - sol_t1["backend_allocations"][s])
assert diff < 0.01, (
f"Service {s}: T5 alloc {sol_t5['backend_allocations'][s]:.4f}% "
f"differs from T1 alloc {sol_t1['backend_allocations'][s]:.4f}% by {diff:.4f}%"
)
def test_service_d_still_at_40pct(self, backend_df, routes_df):
"""T5: Service D must still be at its 40% cap — it is a binding constraint."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
assert abs(sol["backend_allocations"]["D"] - 40.0) < 0.01
def test_services_a_and_e_still_zero(self, backend_df, routes_df):
"""T5: Services A and E must still receive ~0% — they remain uneconomical."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
assert sol["backend_allocations"]["A"] < 0.01
assert sol["backend_allocations"]["E"] < 0.01
def test_no_gateway_exceeds_relaxed_cap(self, backend_df, routes_df):
"""T5: No gateway should exceed the relaxed 90% cap."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
for g, load in sol["gateway_loads"].items():
assert load <= 90.0 + 1e-4, f"Gateway {g} load {load:.4f}% exceeds 90%"
def test_all_constraints_still_satisfied_relaxed_cap(self, backend_df, routes_df):
"""T5: All other constraints must remain satisfied under the relaxed gateway cap."""
sol = build_and_solve(backend_df, routes_df, max_gateway_load=90.0)
assert abs(sum(sol["backend_allocations"].values()) - 100.0) < 1e-4
assert sol["weighted_backend_reliability"] >= 99.5 - 1e-4
assert sol["weighted_route_reliability"] >= 99.5 - 1e-4
assert sol["weighted_avg_cost"] <= 0.40 + 1e-4
for s, pct in sol["backend_allocations"].items():
assert pct <= 40.0 + 1e-4
# ---------------------------------------------------------------------------
# T6: Naive Even-Split Baseline Comparison
# ---------------------------------------------------------------------------
class TestT6NaiveBaselineComparison:
"""
T6: Compare the optimized T1 solution against a naive even-split routing plan
(20% per backend service, equal share per valid route within each service).
Computed analytically without running the optimizer.
"""
def test_naive_latency_is_approximately_193ms(self, backend_df, routes_df):
"""T6: Naive even-split latency should be approximately 193.17 ms."""
baseline = compute_naive_baseline(backend_df, routes_df)
assert abs(baseline["objective_value"] - 193.17) < 0.5, (
f"Naive latency {baseline['objective_value']:.2f} ms not near expected 193.17 ms"
)
def test_naive_backend_reliability_is_99_60(self, backend_df, routes_df):
"""T6: Naive backend reliability should be exactly 99.60% (equal weights over 5 services)."""
baseline = compute_naive_baseline(backend_df, routes_df)
assert abs(baseline["weighted_backend_reliability"] - 99.60) < 1e-6
def test_naive_cost_is_0_20(self, backend_df, routes_df):
"""T6: Naive average cost should be exactly $0.20 (equal weights over 5 services)."""
baseline = compute_naive_baseline(backend_df, routes_df)
assert abs(baseline["weighted_avg_cost"] - 0.20) < 1e-6
def test_naive_route_reliability_above_99_5(self, backend_df, routes_df):
"""T6: Naive route reliability must exceed the 99.5% constraint threshold."""
baseline = compute_naive_baseline(backend_df, routes_df)
assert baseline["weighted_route_reliability"] >= 99.5, (
f"Naive route reliability {baseline['weighted_route_reliability']:.4f}% "
f"is below 99.5% threshold"
)
def test_optimized_latency_strictly_better_than_naive(self, backend_df, routes_df):
"""T6: Optimized latency must be strictly lower than naive latency."""
sol = build_and_solve(backend_df, routes_df)
baseline = compute_naive_baseline(backend_df, routes_df)
assert sol["objective_value"] < baseline["objective_value"], (
f"Optimized latency {sol['objective_value']:.2f} ms is not better than "
f"naive {baseline['objective_value']:.2f} ms"
)
def test_latency_improvement_at_least_30ms(self, backend_df, routes_df):
"""T6: Optimization must reduce latency by at least 30 ms vs naive plan."""
sol = build_and_solve(backend_df, routes_df)
baseline = compute_naive_baseline(backend_df, routes_df)
improvement = baseline["objective_value"] - sol["objective_value"]
assert improvement >= 30.0, (
f"Latency improvement {improvement:.2f} ms is less than the required 30 ms"
)
def test_optimized_cost_lower_than_naive(self, backend_df, routes_df):
"""T6: Optimized average cost must be lower than naive cost of $0.20."""
sol = build_and_solve(backend_df, routes_df)
baseline = compute_naive_baseline(backend_df, routes_df)
assert sol["weighted_avg_cost"] < baseline["weighted_avg_cost"], (
f"Optimized cost ${sol['weighted_avg_cost']:.4f} is not less than "
f"naive cost ${baseline['weighted_avg_cost']:.4f}"
)
def test_naive_backend_allocations_are_20pct_each(self, backend_df, routes_df):
"""T6: Naive plan must allocate exactly 20% to each of the 5 backend services."""
baseline = compute_naive_baseline(backend_df, routes_df)
for s, pct in baseline["backend_allocations"].items():
assert abs(pct - 20.0) < 1e-6, f"Service {s} naive alloc {pct:.4f}% != 20%"
def test_naive_route_allocations_are_equal_within_service(self, backend_df, routes_df):
"""T6: Naive plan must split each service's routes equally."""
baseline = compute_naive_baseline(backend_df, routes_df)
expected = {
"A": pytest.approx(100.0 / 3, abs=1e-4), # 3 routes
"B": pytest.approx(50.0, abs=1e-4), # 2 routes
"C": pytest.approx(100.0 / 3, abs=1e-4), # 3 routes
"D": pytest.approx(50.0, abs=1e-4), # 2 routes
"E": pytest.approx(50.0, abs=1e-4), # 2 routes
}
for (s, g), pct in baseline["route_allocations"].items():
assert pct == expected[s], (
f"Service {s} -> {g}: naive route alloc {pct:.4f}% != expected"
)
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and reports back what happened, so what you get is a model that has been executed, not one that merely looks plausible.
The specification was already complete by the time the data arrived, and attaching the two CSVs doubled as the go-ahead — the same message asked Modeler to finalize the specification, implement it, write tests, run them, and report back.
The specification has no open items left, so there is nothing to ask
before implementing. Writing the GatewayRoutingOptimizer class and a
pytest suite covering scenarios T1-T4, then running the tests. The
full-path weighting decision from pinning down the problem means the
objective and two of the constraints are bilinear in the two decision
layers, so this will solve as a non-convex QP rather than a plain LP.
All 21 tests pass cleanly in under half a second — no failures on the first run.
$ pytest test_network_routing_modeler.py -q
.....................
21 passed in 0.58s
Gurobi Optimizer version 13.0.2 build v13.0.2rc1 (armlinux64 - "Debian GNU/Linux 13 (trixie)")
Optimize a model with 25 rows, 17 columns and 44 nonzeros (Min)
Model has 12 quadratic objective terms
Model has 10 quadratic constraints
Coefficient statistics:
Matrix range [1e-01, 1e+02]
QMatrix range [1e+00, 1e+02]
Bounds range [1e+02, 1e+02]
RHS range [4e+01, 1e+04]
QRHS range [6e+03, 1e+06]
Continuous model is non-convex -- solving as a MIP
Presolved: 57 rows, 31 columns, 154 nonzeros
Presolved model has 12 bilinear constraint(s)
Found heuristic solution: objective 157.2500000
Root relaxation: objective 1.473705e+02, 44 iterations, 0.00 seconds
Cutting planes:
RLT: 5
Explored 1 nodes (57 simplex iterations) in 0.01 seconds (0.00 work units)
Best objective 1.572500000000e+02, best bound 1.572500000000e+02, gap 0.0000%
Two more scenarios were asked for afterward — a robustness check (relaxing a constraint that was already non-binding, to confirm it does not change the answer) and a naive even-split baseline, to quantify what optimization is actually worth. Modeler added both as T5 and T6, re-ran the full suite, and all 37 tests passed, including the original 21:
$ pytest test_network_routing_modeler.py -q
.....................................
37 passed in 1.06s
Here is a full summary of everything that was built:
Results
Optimal solution — T1 baseline
Traffic concentrates on three of the five backend services, for a minimized weighted average end-to-end latency of 157.25 ms:
Service |
Allocation |
Cost / Reliability |
Notes |
|---|---|---|---|
A |
0.00% |
$0.30 / 99.9% |
Too slow and expensive to earn a share |
B |
36.67% |
$0.25 / 99.6% |
Routes: H 75%, I 25% |
C |
23.33% |
$0.10 / 99.0% |
Routes: K 75%, L 25% |
D |
40.00% |
$0.15 / 99.7% |
Pinned at its 40% cap; routes: N 75%, M 25% |
E |
0.00% |
$0.20 / 99.8% |
Too slow and expensive to earn a share |
Site-wide metrics achieved:
Metric |
Value |
|---|---|
Weighted avg latency |
157.25 ms |
Weighted backend reliability |
99.50% (floor 99.5%, binding) |
Weighted route reliability |
99.66% (floor 99.5%) |
Weighted avg cost |
$0.175 (cap $0.40) |
Busiest gateway |
N at 30.00% (cap 60%) |
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Backend services dataset |
|
Valid routes dataset |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1-T6 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — 157.25 ms latency, all constraints satisfied |
PASSED |
T2 |
Cost cap tightened to $0.15 — traffic skews to C and D |
PASSED |
T3 |
Infeasible — reliability floors raised to 99.99% |
PASSED |
T4 |
Gateway load cap tightened to 25% |
PASSED |
T5 |
Gateway load cap relaxed to 90% (non-binding) — answer unchanged |
PASSED |
T6 |
Naive even-split baseline compared against T1 |
PASSED |
Let me know if you would like any changes, such as adjusting a budget or cap, or generating the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct. That second path is what the Changing the model tab demonstrates.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 17 columns and 25 rows, is nowhere near the cap — but a full-scale network with hundreds of services and gateways could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full network for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts costs on the backend-to-gateway hops out of scope — a reasonable simplification, until finance points out that the internal gateways charge a small per-request fee too, and the $0.40 budget was supposed to cover the whole path, not just the first hop. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
One more thing - our internal gateways actually do charge a small per-request cost too, we just didn’t have that data before. Here are the additional costs per route: A->F $0.03, A->G $0.04, A->H $0.02, B->H $0.03, B->I $0.02, C->J $0.04, C->K $0.01, C->L $0.02, D->M $0.01, D->N $0.01, E->F $0.03, E->G $0.03. Can you fold these into the cost constraint so the budget accounts for the full path cost, not just the backend service cost? Please update the spec, implementation and tests, and re-run everything.
This is a scope change, not a data correction: “costs on the
backend-to-gateway routes” is currently listed under out of scope.
Updating the specification first — C4 changes from a backend-only cost
cap to a full-path cost cap, the routes table gains a
route_cost_per_request column, and the out-of-scope bullet comes
out. The implementation needs a new bilinear term in _c4_cost_cap
alongside the existing linear one, since route cost is weighted by the
same full-path share as everything else. Then re-run the whole suite,
not just spot-check — an existing scenario’s numeric threshold might no
longer make sense once route costs are added in.
All 37 tests pass. Here is what changed across all four files:
route_cost_per_request field added to the routes table; T2’s cap updated; T6’s naive cost expectation updated; “no gateway costs” removed from Out of Scope.routes_df gains a route_cost_per_request column; _c4_cost_cap() now sums a linear backend cost term plus a bilinear route cost term; get_solution() and compute_naive_baseline() both compute weighted_avg_cost as backend + route combined.The optimal routing decision is unchanged — B, C and D at the same
allocations as before — but weighted_avg_cost now correctly reports
$0.1920, not the $0.1750 backend-only figure. I’ll walk through exactly
what changed next.
Specification, then code, then tests
The new C4 description, as it now reads in the specification:
C4: Average Full-Path Cost Cap
The weighted average cost per request across the entire system must not
exceed $0.40. Cost is incurred at two layers: a per-request cost at the
backend service layer (weighted by backend allocation), and a per-request
cost on each backend-to-gateway route (weighted by the full-path traffic
share for that route). Both cost components are summed into a single
budget constraint.
The implementation change is purely additive — the backend cost term is untouched, a route cost term is added alongside it:
def _c4_cost_cap(self):
cost = dict(zip(self.backend_df["service"], self.backend_df["cost_per_request"]))
+ route_cost = dict(zip(self._routes, self.routes_df["route_cost_per_request"]))
backend_cost_expr = gp.quicksum(
cost[s] * self._backend_alloc[s] * 100.0
for s in self._services
)
+ route_cost_expr = gp.quicksum(
+ route_cost[s, g] * self._backend_alloc[s] * self._route_alloc[s, g]
+ for s, g in self._routes
+ )
self.model.addConstr(
- backend_cost_expr <= self.max_avg_cost * 100.0,
+ backend_cost_expr + route_cost_expr <= self.max_avg_cost * 10000.0,
name="c4_cost_cap"
)
The route cost term is bilinear — a product of two decision variables, just
like the reliability and gateway-load constraints — so C4 changes from linear
to quadratic. The right-hand side scale changes from * 100.0 to
* 10000.0 to keep both cost components in the same units, matching how C7
and C8 are already scaled.
Where it stops and asks
Adding route costs is mechanical for the implementation, but it made an existing test scenario’s expected number ambiguous. T2’s cap of $0.15 was written against backend-only costs — under full-path costs, does that number mean something different now? Asked directly, Modeler laid out both readings rather than picking one silently:
Reading A — literal $0.15 |
Reading B — rescaled to $0.19 |
|
|---|---|---|
Interpretation |
The dollar figure carries forward unchanged as the new full-path cap |
The cap is re-derived to preserve T2’s original intent: a budget meaningfully tighter than T1’s natural result |
Feasibility |
Infeasible. With the 40% per-service cap active, the cheapest achievable 3-service mix already costs ~$0.154 |
Feasible — sits just below T1’s full-path result of $0.192, so it still forces the optimizer to act |
Consequence |
T2 becomes a second infeasibility test, duplicating T3 |
T2 remains a distinct, meaningful binding-cost scenario |
Both are defensible; neither is obviously “more correct.” Modeler flagged the ambiguity and asked which one to keep rather than defaulting silently:
Reading A (keeping $0.15 as the full-path cap) makes T2 infeasible under the current dataset, because the minimum achievable full-path cost given the 40% per-service cap is approximately $0.154 — already above $0.15. Reading B rescales the cap to $0.19, which sits just below the T1 natural result of $0.192, forces the optimizer to act, and remains feasible. Which would you like — keep Reading B’s $0.19 cap, switch to Reading A’s $0.15 and redesign T2 as a second infeasibility test, or use a different threshold entirely?
Let’s go with option 1 - keep Reading B, the $0.19 full-path cap. That keeps T2 meaningful as a binding-but-feasible cost scenario rather than duplicating T3.
The specification now permanently records this reasoning inside T2’s description, so the next reader of the spec sees why $0.19 is the threshold, not just that it is.
What the cap really costs
The routing decision itself never moved — B, C and D at 36.67% / 23.33% / 40% throughout. What changed is what the numbers mean once every hop has a price:
Plan |
Backend cost |
Route cost |
Full-path cost |
|---|---|---|---|
T1 optimized (backend + route costs) |
$0.1750 |
+$0.0170 |
$0.1920 |
T6 naive even-split (backend + route costs) |
$0.2000 |
+$0.0237 |
$0.2237 |
Optimization’s advantage widens slightly once route costs are counted: the gap between the optimized and naive plans grows from $0.025 (backend-only) to $0.0317 (full-path), because the naive plan’s even split also spreads traffic across the more expensive route legs (A->G at $0.04, C->J at $0.04) that the optimizer never uses in the first place.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(s \in S\): backend services. In the current dataset, \(S = \{A, B, C, D, E\}\).
\(g \in G\): internal gateways. In the current dataset, \(G = \{F, G, H, I, J, K, L, M, N\}\).
\(R \subseteq S \times G\): valid (backend service, internal gateway) route pairs. Only pairs listed in the routes data are members of \(R\); not all combinations of \(S \times G\) are valid.
\(G(s) = \{g \in G \mid (s, g) \in R\}\): internal gateways reachable from backend service \(s\).
\(S(g) = \{s \in S \mid (s, g) \in R\}\): backend services that can reach internal gateway \(g\).
Parameters
\(p_s\): average processing time of backend service \(s\) (ms).
\(r_s\): reliability of backend service \(s\) (%, e.g. 99.9 means 99.9%).
\(c_s\): cost per request routed through backend service \(s\) ($).
\(q_{sg}\): additional processing time on route \((s, g)\) (ms).
\(\rho_{sg}\): additional reliability on route \((s, g)\) (%).
\(\bar{x}\): maximum traffic allocation to any single backend service, 40.0 (%).
\(\underline{r}^{\text{back}}\): minimum weighted backend reliability, 99.5 (%).
\(\bar{c}\): maximum weighted average cost per request, 0.40 ($).
\(\bar{y}\): maximum within-service allocation to any single route, 75.0 (%).
\(\underline{r}^{\text{route}}\): minimum weighted route reliability, 99.5 (%).
\(\bar{L}\): maximum total traffic load on any single internal gateway, 60.0 (%).
Decision variables
\(x_s \in [0, 100]\) for every \(s \in S\): percentage of total incoming traffic allocated to backend service \(s\).
\(y_{sg} \in [0, 100]\) for every \((s, g) \in R\): percentage of backend service \(s\)’s traffic forwarded to internal gateway \(g\).
All variables are expressed in percentage points (0-100) throughout the model.
Objective function
Minimize the weighted average end-to-end latency per request across all full paths. The latency on path \((s, g)\) is \(p_s + q_{sg}\), and its weight in terms of total traffic share is \(\tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100}\):
This is a bilinear (non-convex quadratic) objective, since \(x_s\) and \(y_{sg}\) are both decision variables.
Constraints
C1 — Backend allocation sums to 100%.
C2 — Individual backend service cap.
C3 — Weighted backend reliability.
C4 — Average cost cap.
C5 — Per-service route allocations sum to 100%.
C6 — Individual route cap within a service.
C7 — Weighted route reliability (full-path). The weight of route \((s,g)\) over all traffic is \(\tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100}\):
This is a bilinear (quadratic) constraint in \(x_s\) and \(y_{sg}\).
C8 — Internal gateway total load cap.
Also bilinear (quadratic), one constraint per gateway \(g \in G\).
Complete model
Additional notes
Non-convex quadratic program. Bilinear terms \(x_s \cdot y_{sg}\) appear in the objective, C7 and C8. C1, C2, C3, C5 and C6 are all linear. Neither variable in each bilinear product is fixed, so Gurobi solves this via spatial branch-and-bound with bilinear relaxations, activated automatically once it detects the non-convex quadratic terms — this is why the solver log reports “solving as a MIP” even though every variable is continuous.
Full-path weights form a probability distribution. By C1 and C5, \(\sum_{(s,g) \in R} \tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100} = \sum_{s \in S} \tfrac{x_s}{100} = 1\), so the objective is exactly the expected end-to-end latency under that distribution, and C7 is the expected route reliability under the same distribution.
Sparsity of R. \(|S| = 5\) and \(|G| = 9\) give a full product of 45 pairs, but only \(|R| = 12\) are valid routes here. Variables \(y_{sg}\) are created only for pairs in \(R\), keeping the model compact rather than filtering a dense grid down after the fact.
Degenerate routing when a service carries no traffic. If \(x_s = 0\) for some service (as it does for A and E in the baseline), C5 and C6 still force that service’s \(y_{sg}\) values to sum to 100% and stay under 75% each — but since they are multiplied by \(x_s = 0\) everywhere they appear, their exact split has no effect on the objective or on any other constraint. This is why the solved values for A’s and E’s routes are arbitrary rather than meaningful.
Note that this is the formulation of the original model, before internal gateway costs (the route cost term added to C4) in the Changing the model tab.
### Sets and indices
- $s \in S$: backend services. In the current dataset, $S = \{A, B, C, D, E\}$.
- $g \in G$: internal gateways. In the current dataset, $G = \{F, G, H, I, J, K, L, M, N\}$.
- $R \subseteq S \times G$: valid (backend service, internal gateway) route pairs. Only pairs listed in the routes data are members of $R$; not all combinations of $S \times G$ are valid.
- $G(s) = \{g \in G \mid (s, g) \in R\}$: internal gateways reachable from backend service $s$.
- $S(g) = \{s \in S \mid (s, g) \in R\}$: backend services that can reach internal gateway $g$.
### Parameters
- $p_s$: average processing time of backend service $s$ (ms).
- $r_s$: reliability of backend service $s$ (%, e.g. 99.9 means 99.9%).
- $c_s$: cost per request routed through backend service $s$ ($).
- $q_{sg}$: additional processing time on route $(s, g)$ (ms).
- $\rho_{sg}$: additional reliability on route $(s, g)$ (%).
- $\bar{x}$: maximum traffic allocation to any single backend service, 40.0 (%).
- $\underline{r}^{\text{back}}$: minimum weighted backend reliability, 99.5 (%).
- $\bar{c}$: maximum weighted average cost per request, 0.40 ($).
- $\bar{y}$: maximum within-service allocation to any single route, 75.0 (%).
- $\underline{r}^{\text{route}}$: minimum weighted route reliability, 99.5 (%).
- $\bar{L}$: maximum total traffic load on any single internal gateway, 60.0 (%).
### Decision variables
- $x_s \in [0, 100]$ for every $s \in S$: percentage of total incoming traffic allocated to backend service $s$.
- $y_{sg} \in [0, 100]$ for every $(s, g) \in R$: percentage of backend service $s$'s traffic forwarded to internal gateway $g$.
All variables are expressed in percentage points (0-100) throughout the model.
### Objective function
Minimize the weighted average end-to-end latency per request across all full
paths. The latency on path $(s, g)$ is $p_s + q_{sg}$, and its weight in terms
of total traffic share is $\tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100}$:
$$
\min \quad \frac{1}{10000} \sum_{(s,g) \in R} \left( p_s + q_{sg} \right) \, x_s \, y_{sg}
$$
This is a **bilinear (non-convex quadratic)** objective, since $x_s$ and
$y_{sg}$ are both decision variables.
### Constraints
**C1 — Backend allocation sums to 100%.**
$$
\sum_{s \in S} x_s = 100
$$
**C2 — Individual backend service cap.**
$$
x_s \leq \bar{x} \qquad \forall s \in S
$$
**C3 — Weighted backend reliability.**
$$
\sum_{s \in S} r_s \, x_s \geq \underline{r}^{\text{back}} \cdot 100
$$
**C4 — Average cost cap.**
$$
\sum_{s \in S} c_s \, x_s \leq \bar{c} \cdot 100
$$
**C5 — Per-service route allocations sum to 100%.**
$$
\sum_{g \in G(s)} y_{sg} = 100 \qquad \forall s \in S
$$
**C6 — Individual route cap within a service.**
$$
y_{sg} \leq \bar{y} \qquad \forall (s,g) \in R
$$
**C7 — Weighted route reliability (full-path).** The weight of route $(s,g)$
over all traffic is $\tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100}$:
$$
\sum_{(s,g) \in R} \rho_{sg} \, x_s \, y_{sg} \geq \underline{r}^{\text{route}} \cdot 10000
$$
This is a **bilinear (quadratic) constraint** in $x_s$ and $y_{sg}$.
**C8 — Internal gateway total load cap.**
$$
\sum_{s \in S(g)} x_s \, y_{sg} \leq \bar{L} \cdot 100 \qquad \forall g \in G
$$
Also **bilinear (quadratic)**, one constraint per gateway $g \in G$.
### Complete model
$$
\begin{aligned}
\min \quad & \frac{1}{10000} \sum_{(s,g) \in R} \left( p_s + q_{sg} \right) x_s \, y_{sg} \\[6pt]
\text{s.t.} \quad & \sum_{s \in S} x_s = 100 & \text{(C1)} \\[4pt]
& x_s \leq \bar{x} & \forall s \in S & \quad \text{(C2)} \\[4pt]
& \sum_{s \in S} r_s \, x_s \geq \underline{r}^{\text{back}} \cdot 100 & \text{(C3)} \\[4pt]
& \sum_{s \in S} c_s \, x_s \leq \bar{c} \cdot 100 & \text{(C4)} \\[4pt]
& \sum_{g \in G(s)} y_{sg} = 100 & \forall s \in S & \quad \text{(C5)} \\[4pt]
& y_{sg} \leq \bar{y} & \forall (s,g) \in R & \quad \text{(C6)} \\[4pt]
& \sum_{(s,g) \in R} \rho_{sg} \, x_s \, y_{sg} \geq \underline{r}^{\text{route}} \cdot 10000 & \text{(C7)} \\[4pt]
& \sum_{s \in S(g)} x_s \, y_{sg} \leq \bar{L} \cdot 100 & \forall g \in G & \quad \text{(C8)} \\[4pt]
& 0 \leq x_s \leq 100 & \forall s \in S \\[4pt]
& 0 \leq y_{sg} \leq 100 & \forall (s,g) \in R
\end{aligned}
$$
### Additional notes
- **Non-convex quadratic program.** Bilinear terms $x_s \cdot y_{sg}$ appear in
the objective, C7 and C8. C1, C2, C3, C5 and C6 are all linear. Neither
variable in each bilinear product is fixed, so Gurobi solves this via
spatial branch-and-bound with bilinear relaxations, activated automatically
once it detects the non-convex quadratic terms — this is why the solver log
reports "solving as a MIP" even though every variable is continuous.
- **Full-path weights form a probability distribution.** By C1 and C5,
$\sum_{(s,g) \in R} \tfrac{x_s}{100} \cdot \tfrac{y_{sg}}{100} = \sum_{s \in S} \tfrac{x_s}{100} = 1$,
so the objective is exactly the expected end-to-end latency under that
distribution, and C7 is the expected route reliability under the same
distribution.
- **Sparsity of R.** $|S| = 5$ and $|G| = 9$ give a full product of 45 pairs,
but only $|R| = 12$ are valid routes here. Variables $y_{sg}$ are created
only for pairs in $R$, keeping the model compact rather than filtering a
dense grid down after the fact.
- **Degenerate routing when a service carries no traffic.** If $x_s = 0$ for
some service (as it does for A and E in the baseline), C5 and C6 still force
that service's $y_{sg}$ values to sum to 100% and stay under 75% each — but
since they are multiplied by $x_s = 0$ everywhere they appear, their exact
split has no effect on the objective or on any other constraint. This is why
the solved values for A's and E's routes are arbitrary rather than
meaningful.
Note that this is the formulation of the original model, before internal
gateway costs (the route cost term added to C4) in the **Changing the model**
tab.