LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
sage_rest_engine.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_SYM_SAGE_REST_ENGINE_H
6#define LINE_API_SYM_SAGE_REST_ENGINE_H
7
8/**
9 * @file
10 * @ingroup api_sym
11 * SymEngine backed by the line-sage-rest service.
12 *
13 * Port of jline.api.sym.SageRestEngine. The service is SageMath behind the JSON
14 * protocol in io/sage/server.py. Every request is a single POST carrying the whole
15 * problem, so nothing is bind-mounted and the client works against a container,
16 * a remote host or a hand-started server alike.
17 *
18 * NUMERIC COEFFICIENTS ARE SENT AS DECIMAL STRINGS and read server side as
19 * exact rationals, which is what keeps the solve exact: a double coerced by the
20 * CAS would carry the binary rational nearest the decimal instead, and the
21 * difference survives all the way into the printed normal form. The same rule
22 * applies to the assignment eval() sends.
23 *
24 * The server enforces the timeout too, so a runaway symbolic solve is killed
25 * there rather than merely abandoned here.
26 */
27
28#include <cmath>
29#include <cstddef>
30#include <cstdio>
31#include <cstdlib>
32#include <iostream>
33#include <map>
34#include <mutex>
35#include <string>
36#include <vector>
37
38#include "json.hpp"
40#include "line/util/error.h"
41#include "line/util/http.h"
42
43namespace line {
44namespace sym {
45
46namespace detail {
47
48using Json = nlohmann::json;
49
50/**
51 * Canary for SageRestEngine::isUsable: the weighted-average softmin form, the
52 * smallest expression observed to kill a worker whose FLINT wants BMI2/ADX on
53 * a CPU that has neither. Its argument is a 17-digit decimal so the exact
54 * rational is multi-limb, which is what reaches the offending routine.
55 */
56inline const char* canary_expr() {
57 return "(x*exp(-x) + exp(-1))/(exp(-x) + exp(-1))";
58}
59
60/** @return the canary's argument */
61inline const char* canary_arg() { return "0.68999999999999995"; }
62
63/** The canary's value. */
64const double CANARY_VALUE = 0.82116556904906557;
65
66/** @return the usability verdicts, keyed by base URL */
67inline std::map<std::string, bool>& usable_cache() {
68 static std::map<std::string, bool> cache;
69 return cache;
70}
71
72/** @return the mutex guarding usable_cache */
73inline std::mutex& usable_mutex() {
74 static std::mutex m;
75 return m;
76}
77
78/**
79 * The shortest decimal literal that round-trips to v, i.e. what Java's
80 * Double.toString sends. Twin of line::reg::shortest_decimal, repeated here so
81 * that api/sym stays independent of the numeric core it never otherwise uses.
82 */
83inline std::string decimal_string(double v) {
84 char buf[64];
85 for (int prec = 1; prec <= 17; ++prec) {
86 std::snprintf(buf, sizeof(buf), "%.*g", prec, v);
87 if (std::strtod(buf, nullptr) == v) return std::string(buf);
88 }
89 std::snprintf(buf, sizeof(buf), "%.17g", v);
90 return std::string(buf);
91}
92
93/** Reads a string array field, empty when the field is absent or null. */
94inline std::vector<std::string> to_string_list(const Json& obj, const std::string& field) {
95 std::vector<std::string> out;
96 if (!obj.contains(field) || obj[field].is_null()) return out;
97 for (const Json& el : obj[field]) out.push_back(el.get<std::string>());
98 return out;
99}
100
101/** Reads a string field, or the fallback when it is absent or null. */
102inline std::string opt_string(const Json& obj, const std::string& field,
103 const std::string& fallback) {
104 if (obj.contains(field) && !obj[field].is_null()) return obj[field].get<std::string>();
105 return fallback;
106}
107
108/** Encodes a square expression matrix, refusing a ragged one. */
109inline Json to_json_matrix(const std::vector<std::vector<std::string>>& Q) {
110 if (Q.empty()) throw InputError("SageRestEngine: Q must not be empty");
111 Json rows = Json::array();
112 for (std::size_t i = 0; i < Q.size(); ++i) {
113 if (Q[i].size() != Q.size())
114 throw InputError("SageRestEngine: Q must be square, row " + std::to_string(i) +
115 " has " + std::to_string(Q[i].size()) + " entries but Q has " +
116 std::to_string(Q.size()) + " rows");
117 Json row = Json::array();
118 for (std::size_t j = 0; j < Q[i].size(); ++j)
119 row.push_back(Q[i][j].empty() ? std::string("0") : Q[i][j]);
120 rows.push_back(row);
121 }
122 return rows;
123}
124
125/**
126 * Encodes a string list, dropping empty entries. An empty symbol marks an event
127 * with no positive rate, as symbolicGeneratorResult.symbols does in the JAR; it
128 * contributes nothing and must not reach the server as "".
129 */
130inline Json to_json_array(const std::vector<std::string>& items) {
131 Json arr = Json::array();
132 for (std::size_t i = 0; i < items.size(); ++i)
133 if (!items[i].empty()) arr.push_back(items[i]);
134 return arr;
135}
136
137} // namespace detail
138
139/** Client of the line-sage-rest service. */
140class SageRestEngine : public SymEngine {
141public:
142 /** Default per-request timeout, in seconds. */
143 static constexpr int DEFAULT_TIMEOUT_SECONDS = 300;
144
145 /**
146 * @param baseUrl base URL of the service, e.g. "http://localhost:8080"
147 */
148 explicit SageRestEngine(const std::string& baseUrl)
149 : timeoutSeconds_(DEFAULT_TIMEOUT_SECONDS) {
150 std::string u = baseUrl;
151 const std::size_t b = u.find_first_not_of(" \t\r\n");
152 const std::size_t e = u.find_last_not_of(" \t\r\n");
153 u = b == std::string::npos ? std::string() : u.substr(b, e - b + 1);
154 while (!u.empty() && u[u.size() - 1] == '/') u.erase(u.size() - 1);
155 if (u.empty()) throw InputError("SageRestEngine: baseUrl must not be empty");
156 baseUrl_ = u;
157 }
158
159 /**
160 * Sets the per-request timeout.
161 *
162 * @param seconds timeout in seconds; not positive disables it
163 * @return this engine
164 */
166 timeoutSeconds_ = seconds;
167 return *this;
168 }
169
170 /** @return the per-request timeout in seconds */
171 int getTimeoutSeconds() const { return timeoutSeconds_; }
172
173 /** @return the base URL this engine posts to */
174 const std::string& getBaseUrl() const { return baseUrl_; }
175
176 std::string name() const override { return "sage"; }
177
178 bool isAvailable() const override {
179 try {
180 const detail::Json health = get("/api/v1/health", 5000);
181 return detail::opt_string(health, "status", "") == "ok";
182 } catch (const Error&) {
183 return false;
184 }
185 }
186
187 /**
188 * Checks that the service can actually EVALUATE, not merely that it
189 * answers.
190 *
191 * The line-sage-rest image ships a FLINT built for CPUs that have BMI2 and
192 * ADX. On an older host the first multi-limb exact operation raises
193 * SIGILL, the worker dies mid-request and the call returns no bytes at
194 * all; /api/v1/health is pure Python and keeps answering, so it cannot see
195 * this. The canary is the weighted-average softmin form, which is what the
196 * fluid export actually sends, and is the smallest expression observed to
197 * trigger it. Verdicts are cached per URL, so this costs one small request
198 * the first time a service is considered and nothing after. See
199 * _kb/11-conventions-and-gotchas.md.
200 *
201 * @return true if the service returned the canary's value
202 */
203 bool isUsable() const {
204 {
205 std::lock_guard<std::mutex> guard(detail::usable_mutex());
206 std::map<std::string, bool>& cache = detail::usable_cache();
207 const std::map<std::string, bool>::const_iterator it = cache.find(baseUrl_);
208 if (it != cache.end()) return it->second;
209 }
210 bool ok = false;
211 try {
212 detail::Json request;
213 request["exprs"] = detail::Json::array({std::string(detail::canary_expr())});
214 detail::Json values = detail::Json::object();
215 values["x"] = std::string(detail::canary_arg());
216 request["values"] = values;
217 request["timeout_s"] = 30;
218 const detail::Json response =
219 parse(http::post_json(baseUrl_ + "/api/v1/eval", request.dump(), 60000));
220 checkStatus("/api/v1/eval", response);
221 if (response.contains("values") && response["values"].is_array() &&
222 response["values"].size() == 1 && !response["values"][0].is_null()) {
223 const double v = response["values"][0].get<double>();
224 ok = std::fabs(v - detail::CANARY_VALUE) < 1e-9;
225 }
226 } catch (const Error&) {
227 // A dead worker closes the connection without a reply, which
228 // surfaces as a transport error rather than a service one. Either
229 // way the backend cannot serve us.
230 ok = false;
231 }
232 if (!ok) {
233 std::cerr << "[LINE] Ignoring symbolic backend at " << baseUrl_
234 << ": it did not return the usability canary. On a CPU without "
235 << "BMI2/ADX the image's FLINT raises SIGILL mid-request." << std::endl;
236 }
237 {
238 std::lock_guard<std::mutex> guard(detail::usable_mutex());
239 detail::usable_cache()[baseUrl_] = ok;
240 }
241 return ok;
242 }
243
244 /**
245 * Reads the service identity, used to tell a line-sage-rest server apart
246 * from another line-*-rest service on the same conventional port.
247 *
248 * @return the /api/v1/info document
249 */
250 detail::Json info() const { return get("/api/v1/info", 5000); }
251
252 CtmcSolution solveCTMC(const std::vector<std::vector<std::string>>& Q,
253 const std::vector<std::string>& symbols) override {
254 detail::Json request;
255 request["Q"] = detail::to_json_matrix(Q);
256 request["symbols"] = detail::to_json_array(symbols);
257 request["normalize"] = true;
258 const detail::Json response = post("/api/v1/ctmc/solve", request);
259
260 CtmcSolution sol;
261 sol.pi = detail::to_string_list(response, "pi");
262 sol.num = detail::to_string_list(response, "num");
263 sol.den = detail::opt_string(response, "den", "1");
264 sol.nConnComp = response.contains("nConnComp") ? response["nConnComp"].get<int>() : 1;
265 if (response.contains("connComp") && !response["connComp"].is_null())
266 for (const detail::Json& el : response["connComp"]) sol.connComp.push_back(el.get<int>());
267 return sol;
268 }
269
270 SymSensitivity ctmcSensitivity(const std::vector<std::vector<std::string>>& Q,
271 const std::vector<std::string>& symbols,
272 const std::string& theta,
273 const std::vector<std::string>& reward) override {
274 detail::Json request;
275 request["Q"] = detail::to_json_matrix(Q);
276 request["symbols"] = detail::to_json_array(symbols);
277 request["theta"] = theta;
278 if (!reward.empty()) request["reward"] = detail::to_json_array(reward);
279 const detail::Json response = post("/api/v1/ctmc/sensitivity", request);
280
282 s.pi = detail::to_string_list(response, "pi");
283 s.dpi = detail::to_string_list(response, "dpi");
284 s.Er = detail::opt_string(response, "Er", "");
285 s.S = detail::opt_string(response, "S", "");
286 s.SS = detail::opt_string(response, "SS", "");
287 s.hasReward = !reward.empty();
288 return s;
289 }
290
291 std::vector<std::string> simplify(const std::vector<std::string>& exprs,
292 const std::string& form) override {
293 detail::Json request;
294 request["exprs"] = detail::to_json_array(exprs);
295 request["form"] = form.empty() ? std::string("cancel") : form;
296 return detail::to_string_list(post("/api/v1/simplify", request), "results");
297 }
298
299 std::vector<std::string> diff(const std::vector<std::string>& exprs,
300 const std::string& variable, int order) override {
301 detail::Json request;
302 request["exprs"] = detail::to_json_array(exprs);
303 request["var"] = variable;
304 request["order"] = order;
305 return detail::to_string_list(post("/api/v1/diff", request), "results");
306 }
307
308 std::vector<double> eval(const std::vector<std::string>& exprs,
309 const std::map<std::string, double>& assignment) override {
310 detail::Json request;
311 request["exprs"] = detail::to_json_array(exprs);
312 detail::Json values = detail::Json::object();
313 for (std::map<std::string, double>::const_iterator it = assignment.begin();
314 it != assignment.end(); ++it) {
315 // sent as text so the server reads the decimal exactly, see the
316 // header comment
317 values[it->first] = detail::decimal_string(it->second);
318 }
319 request["values"] = values;
320 const detail::Json response = post("/api/v1/eval", request);
321
322 std::vector<double> out;
323 if (!response.contains("values") || response["values"].is_null()) return out;
324 for (const detail::Json& el : response["values"])
325 out.push_back(el.is_null() ? std::nan("") : el.get<double>());
326 return out;
327 }
328
329 FluidODEs fluidODEs(const std::vector<std::string>& rhs, const std::vector<std::string>& vars,
330 const std::vector<std::string>& want) override {
331 detail::Json request;
332 request["rhs"] = detail::to_json_array(rhs);
333 request["vars"] = detail::to_json_array(vars);
334 request["want"] = detail::to_json_array(want);
335 const detail::Json response = post("/api/v1/fluid/odes", request);
336
337 FluidODEs odes;
338 if (response.contains("jacobian") && !response["jacobian"].is_null()) {
339 odes.hasJacobian = true;
340 for (const detail::Json& row : response["jacobian"]) {
341 std::vector<std::string> r;
342 for (const detail::Json& el : row) r.push_back(el.get<std::string>());
343 odes.jacobian.push_back(r);
344 }
345 }
346 if (response.contains("latex") && !response["latex"].is_null()) {
347 odes.hasLatex = true;
348 odes.latex = detail::to_string_list(response, "latex");
349 }
350 if (response.contains("equilibria") && !response["equilibria"].is_null()) {
351 odes.hasEquilibria = true;
352 for (const detail::Json& sol : response["equilibria"]) {
353 std::map<std::string, std::string> m;
354 for (detail::Json::const_iterator it = sol.begin(); it != sol.end(); ++it)
355 m[it.key()] = it.value().get<std::string>();
356 odes.equilibria.push_back(m);
357 }
358 }
359 return odes;
360 }
361
362private:
363 detail::Json post(const std::string& path, detail::Json request) const {
364 const int millis = timeoutSeconds_ > 0 ? timeoutSeconds_ * 1000 : 0;
365 if (timeoutSeconds_ > 0) request["timeout_s"] = timeoutSeconds_;
366 const detail::Json response = parse(
367 http::post_json(baseUrl_ + path, request.dump(), millis));
368 checkStatus(path, response);
369 return response;
370 }
371
372 detail::Json get(const std::string& path, int millis) const {
373 return parse(http::get(baseUrl_ + path, millis));
374 }
375
376 static detail::Json parse(const http::Response& response) {
377 if (response.body.empty())
378 throw SymEngineError("line-sage-rest returned HTTP " +
379 std::to_string(response.status) + " with no body");
380 detail::Json parsed = detail::Json::parse(response.body, nullptr, false);
381 if (parsed.is_discarded() || !parsed.is_object())
382 throw SymEngineError("line-sage-rest returned a non-JSON body: " + response.body);
383 return parsed;
384 }
385
386 static void checkStatus(const std::string& path, const detail::Json& response) {
387 const std::string status = detail::opt_string(response, "status", "");
388 if (status == "ok") return;
389 throw SymEngineError("line-sage-rest " + path + " failed [" +
390 detail::opt_string(response, "code", "error") +
391 "]: " + detail::opt_string(response, "message", "unspecified error"));
392 }
393
394 std::string baseUrl_;
395 int timeoutSeconds_;
396};
397
398} // namespace sym
399} // namespace line
400
401#endif // LINE_API_SYM_SAGE_REST_ENGINE_H
Base error for the multiprecision C++ port.
Definition error.h:31
InputError(const std::string &what)
Definition error.h:39
SageRestEngine(const std::string &baseUrl)
static constexpr int DEFAULT_TIMEOUT_SECONDS
Default per-request timeout, in seconds.
std::vector< double > eval(const std::vector< std::string > &exprs, const std::map< std::string, double > &assignment) override
Substitutes values for symbols and evaluates.
detail::Json info() const
Reads the service identity, used to tell a line-sage-rest server apart from another line-*-rest servi...
CtmcSolution solveCTMC(const std::vector< std::vector< std::string > > &Q, const std::vector< std::string > &symbols) override
Symbolic stationary distribution of a CTMC, pi Q = 0 with sum(pi) = 1.
FluidODEs fluidODEs(const std::vector< std::string > &rhs, const std::vector< std::string > &vars, const std::vector< std::string > &want) override
Jacobian, LaTeX form and equilibria of a fluid vector field.
bool isUsable() const
Checks that the service can actually EVALUATE, not merely that it answers.
std::string name() const override
Name of the backing engine, e.g.
bool isAvailable() const override
True if the engine answers a health probe.
std::vector< std::string > diff(const std::vector< std::string > &exprs, const std::string &variable, int order) override
Differentiates expressions.
SageRestEngine & setTimeoutSeconds(int seconds)
Sets the per-request timeout.
const std::string & getBaseUrl() const
SymSensitivity ctmcSensitivity(const std::vector< std::vector< std::string > > &Q, const std::vector< std::string > &symbols, const std::string &theta, const std::vector< std::string > &reward) override
Exact parametric sensitivity of a steady-state reward.
std::vector< std::string > simplify(const std::vector< std::string > &exprs, const std::string &form) override
Rewrites expressions into a normal form.
SymEngineError(const std::string &what)
Definition sym_engine.h:43
A computer algebra backend.
Definition sym_engine.h:82
The exception types the port throws.
Minimal HTTP/1.1 client, enough to talk to a line-*-rest service.
Response post_json(const std::string &url, const std::string &json, int timeoutMillis)
POST a JSON document.
Definition http.h:361
Response get(const std::string &url, int timeoutMillis)
GET a URL.
Definition http.h:349
Symbolic stationary distribution of a CTMC.
Definition sym_engine.h:47
std::vector< std::string > pi
Stationary probability of each state, as an expression.
Definition sym_engine.h:48
std::vector< int > connComp
Component index of each state, one based.
Definition sym_engine.h:52
std::vector< std::string > num
Numerator of each entry over the common denominator.
Definition sym_engine.h:49
int nConnComp
Weakly connected components of the generator.
Definition sym_engine.h:51
std::string den
Common denominator of the whole vector.
Definition sym_engine.h:50
Symbolic analysis of a fluid vector field.
Definition sym_engine.h:72
std::vector< std::string > latex
LaTeX form of each right hand side.
Definition sym_engine.h:74
std::vector< std::map< std::string, std::string > > equilibria
variable -> expression
Definition sym_engine.h:75
std::vector< std::vector< std::string > > jacobian
d f_i / d x_j
Definition sym_engine.h:73
Exact parametric sensitivity, following Trivedi and Bobbio (2017), Sec.
Definition sym_engine.h:62
bool hasReward
Whether Er, S and SS were computed.
Definition sym_engine.h:68
std::vector< std::string > dpi
Derivative of the distribution with respect to theta.
Definition sym_engine.h:64
std::vector< std::string > pi
Stationary distribution.
Definition sym_engine.h:63
std::string SS
Scaled sensitivity (theta/E[r]) d(E[r])/dtheta, Eq. (9.80).
Definition sym_engine.h:67
std::string Er
Mean reward, empty if no reward was given.
Definition sym_engine.h:65
std::string S
Unscaled sensitivity d(E[r])/dtheta, Eq. (9.79).
Definition sym_engine.h:66
Computer algebra operations LINE needs, as seen by this port.