2 % SAGE MATLAB-to-SageMath bridge
for symbolic analysis.
4 % Static helpers that talk to the line-sage-rest service, the same JSON
5 % protocol the JAR (jline.api.sym) and native Python
6 % (line_solver.api.sym) use. The service
is SageMath in a container; see
7 % sage/server.py for the endpoints.
9 % It exists for two reasons. The Symbolic Math Toolbox
is licence-gated,
10 % so a session without it cannot run any symbolic analysis at all; and
11 % where both are present, routing through one engine gives all three
12 % codebases the same normal
form, which
is what makes a symbolic result
13 % comparable across them.
15 % Backend resolution, in order:
16 % 1. an explicit URL in options.config.symbolic;
17 % 2. the LINE_SAGE_URL environment variable;
18 % 3. a line-sage-rest service already listening on a conventional port;
19 % 4. a container started here from a locally present image;
20 % 5. nothing, in which case the caller falls back to the toolbox.
22 % Step 3 checks identity through /api/v1/info rather than trusting the
23 % port: every imperialqore line-*-rest service listens on 8080 by
24 % convention, so a health probe alone would accept the LQNS service.
26 % Expressions cross as plain
char infix strings, e.g. '2*x1 - 3*x2'.
27 % Numeric coefficients are read server side as exact rationals, so
28 % '0.1'
is 1/10 and not the binary
double nearest to it.
30 % Copyright (c) 2012-2026, Imperial College London
31 % All rights reserved.
34 DOCKER_IMAGES = {
'imperialqore/line-sage-rest:latest', ...
35 'imperialqore/line-sage-rest'};
36 PROBE_PORTS = [8085, 8080];
37 STARTUP_TIMEOUT = 120; % seconds to wait
for a container to answer
38 DEFAULT_TIMEOUT = 300; % seconds per request
43 function url = resolve(requested)
44 % RESOLVE Base URL of a usable service, or
'' if there
is none.
46 % REQUESTED
is '' or
'auto' to search, a URL to use a specific
47 % service,
'none' to disable the backend, or an image name.
49 if nargin < 1 || isempty(requested)
52 requested = strtrim(requested);
53 if any(strcmpi(requested, {
'none',
'off'}))
57 if strncmpi(requested, 'http:
59 if ~SAGE.isReachable(url)
65 env = getenv('LINE_SAGE_URL');
66 if ~isempty(env) && SAGE.isReachable(env)
71 if ~isempty(cachedUrl) && SAGE.isReachable(cachedUrl)
76 for p = SAGE.PROBE_PORTS
77 candidate = sprintf('http:
78 if SAGE.isSageService(candidate)
85 if any(strcmpi(requested, {
'auto',
'true',
'sage'}))
86 %
'sage' names the ENGINE, not a Docker image: taking it as an
87 % image name sends docker looking
for a repository called
88 %
"sage" and the start fails with a pull-access error that
89 % reads like a login problem.
90 image = SAGE.getDockerImage();
98 url = SAGE.startContainer(image);
102 function
bool = isAvailable(requested)
103 % ISAVAILABLE True
if a symbolic backend can be resolved.
107 bool = ~isempty(SAGE.resolve(requested));
110 function img = getDockerImage()
111 % GETDOCKERIMAGE First locally present image tag, or
'' if none.
116 if unix(
'docker info >/dev/null 2>&1') ~= 0
119 for i = 1:numel(SAGE.DOCKER_IMAGES)
120 [st, out] = unix([
'docker images -q ', SAGE.DOCKER_IMAGES{i},
' 2>/dev/null']);
121 if st == 0 && ~isempty(strtrim(out))
122 img = SAGE.DOCKER_IMAGES{i};
128 function url = startContainer(image)
129 % STARTCONTAINER Run the service and wait
for it to answer.
131 % The container
is bound to a free host port, so several MATLAB
132 % sessions, or a session next to a hand-started service,
do not
133 % collide. It
is left running: MATLAB has no reliable exit hook,
134 % and a warm container
is what makes repeated symbolic calls
135 % cheap. Stop it with SAGE.stopContainer.
137 port = SAGE.freePort();
138 name = sprintf(
'line-sage-rest-%d', port);
139 cmd = sprintf(
'docker run -d --rm --name %s -p %d:8080 %s', name, port, image);
140 [st, out] = unix([cmd,
' 2>&1']);
142 line_warning(mfilename,
'Could not start %s: %s\n', image, strtrim(out));
145 candidate = sprintf(
'http://localhost:%d', port);
147 while toc(deadline) < SAGE.STARTUP_TIMEOUT
148 if SAGE.isReachable(candidate)
154 unix(sprintf(
'docker stop -t 1 %s >/dev/null 2>&1', name));
155 line_warning(mfilename, ...
156 'Container %s did not answer within %d s.\n', name, SAGE.STARTUP_TIMEOUT);
159 function stopContainer()
160 % STOPCONTAINER Stop every container
this machine started here.
164 unix([
'for c in $(docker ps -q --filter name=line-sage-rest-); do ', ...
165 'docker stop -t 1 $c >/dev/null 2>&1; done']);
168 function
bool = isReachable(url)
169 % ISREACHABLE True
if the service answers a health probe.
172 opts = weboptions(
'Timeout', 5,
'ContentType',
'json');
173 r = webread([SAGE.trimUrl(url),
'/api/v1/health'], opts);
174 bool = isfield(r,
'status') && strcmp(r.status,
'ok');
179 function
bool = isSageService(url)
180 % ISSAGESERVICE True
if the service
is line-sage-rest and not
181 % another line-*-rest service sharing the conventional port.
184 opts = weboptions(
'Timeout', 5,
'ContentType',
'json');
185 r = webread([SAGE.trimUrl(url),
'/api/v1/info'], opts);
186 bool = isfield(r,
'sage_version');
191 % ---------------------------------------------------------------
193 % ---------------------------------------------------------------
195 function [pi, num, den, nConnComp, connComp] = solveCTMC(Q, symbols, url, timeout)
196 % SOLVECTMC Symbolic stationary distribution, pi*Q = 0, sum(pi) = 1.
198 % Q
is a cell array of expression strings or a sym matrix, SYMBOLS
199 % a cellstr of the symbols occurring in it. PI comes back as a
200 % cellstr, or as a sym vector when the Symbolic Toolbox
is present.
201 if nargin < 3 || isempty(url)
202 url = SAGE.require();
205 timeout = SAGE.DEFAULT_TIMEOUT;
207 [Qcell, symbols] = SAGE.toExpressionMatrix(Q, symbols);
208 payload =
struct(
'Q', {Qcell},
'symbols', {symbols(:)
'}, ...
209 'normalize
', true, 'timeout_s
', timeout);
210 r = SAGE.post(url, '/api/v1/ctmc/solve
', payload, timeout);
211 pi = SAGE.toSymOrChar(r.pi);
212 num = SAGE.toSymOrChar(r.num);
213 den = SAGE.toSymOrChar({r.den});
217 nConnComp = double(r.nConnComp);
218 connComp = double(r.connComp(:))';
221 function [dpi, S, SS, pi] = sensitivity(Q, symbols, theta, reward, url, timeout)
222 % SENSITIVITY Exact d(pi)/d(theta) and, given a reward, d(E[r])/d(theta).
224 % Exact where @SolverCTMC/getSensitivity uses a central difference
225 % accurate to O(h^2). As there, dr/dtheta
is taken to be zero.
226 if nargin < 5 || isempty(url)
227 url = SAGE.require();
230 timeout = SAGE.DEFAULT_TIMEOUT;
232 [Qcell, symbols] = SAGE.toExpressionMatrix(Q, symbols);
233 payload =
struct(
'Q', {Qcell},
'symbols', {symbols(:)
'}, ...
234 'theta
', theta, 'timeout_s
', timeout);
235 if nargin >= 4 && ~isempty(reward)
236 payload.reward = SAGE.toExpressionList(reward);
238 r = SAGE.post(url, '/api/v1/ctmc/sensitivity
', payload, timeout);
239 dpi = SAGE.toSymOrChar(r.dpi);
240 pi = SAGE.toSymOrChar(r.pi);
244 S = SAGE.scalarSymOrChar(r.S);
247 SS = SAGE.scalarSymOrChar(r.SS);
251 function out = simplify(exprs, form, url, timeout)
252 % SIMPLIFY Rewrite expressions: simplify, factor, together,
253 % cancel, expand or latex.
254 if nargin < 2 || isempty(form)
257 if nargin < 3 || isempty(url)
258 url = SAGE.require();
261 timeout = SAGE.DEFAULT_TIMEOUT;
263 payload = struct('exprs
', {SAGE.toExpressionList(exprs)}, ...
264 'form', form, 'timeout_s
', timeout);
265 r = SAGE.post(url, '/api/v1/simplify
', payload, timeout);
266 out = SAGE.asCellstr(r.results);
269 function out = diff(exprs, variable, order, url, timeout)
270 % DIFF Differentiate expressions with respect to VARIABLE.
271 if nargin < 3 || isempty(order)
274 if nargin < 4 || isempty(url)
275 url = SAGE.require();
278 timeout = SAGE.DEFAULT_TIMEOUT;
280 payload = struct('exprs
', {SAGE.toExpressionList(exprs)}, ...
281 'var
', variable, 'order
', order, 'timeout_s
', timeout);
282 r = SAGE.post(url, '/api/v1/diff
', payload, timeout);
283 out = SAGE.asCellstr(r.results);
286 function [values, exact] = eval(exprs, assignment, url, timeout)
287 % EVAL Substitute values for symbols and evaluate.
289 % ASSIGNMENT is a struct whose fields are symbol names. This is how
290 % symbolic results are compared across codebases: symbol numbering
291 % follows event enumeration order, so comparing expression text is
292 % unsound while comparing substituted values is not.
293 if nargin < 3 || isempty(url)
294 url = SAGE.require();
297 timeout = SAGE.DEFAULT_TIMEOUT;
299 names = fieldnames(assignment);
300 values_in = struct();
301 for i = 1:numel(names)
302 % Sent as text so the server reads the decimal exactly.
303 values_in.(names{i}) = sprintf('%.17g
', assignment.(names{i}));
305 payload = struct('exprs
', {SAGE.toExpressionList(exprs)}, ...
306 'values
', values_in, 'timeout_s
', timeout);
307 r = SAGE.post(url, '/api/v1/eval
', payload, timeout);
310 values = nan(1, numel(raw));
312 if ~isempty(raw{i}) && isnumeric(raw{i})
317 values = double(raw(:))';
319 exact = SAGE.asCellstr(r.exact);
322 function [jacobian, latexForm, equilibria] = fluidODEs(rhs, vars, want, url, timeout)
323 % FLUIDODES Jacobian, LaTeX
form and equilibria of a fluid drift.
324 if nargin < 3 || isempty(want)
325 want = {
'jacobian',
'latex'};
327 if nargin < 4 || isempty(url)
328 url = SAGE.require();
331 timeout = SAGE.DEFAULT_TIMEOUT;
333 payload =
struct(
'rhs', {SAGE.toExpressionList(rhs)}, ...
334 'vars', {SAGE.toExpressionList(vars)}, ...
335 'want', {want(:)
'}, 'timeout_s
', timeout);
336 r = SAGE.post(url, '/api/v1/fluid/odes
', payload, timeout);
340 if isfield(r, 'jacobian
')
341 jacobian = SAGE.asCellMatrix(r.jacobian);
343 if isfield(r, 'latex
')
344 latexForm = SAGE.asCellstr(r.latex);
346 if isfield(r, 'equilibria
')
347 equilibria = r.equilibria;
351 % ---------------------------------------------------------------
353 % ---------------------------------------------------------------
355 function url = require()
356 % REQUIRE Resolve a backend or error with how to get one.
357 url = SAGE.resolve('auto
');
359 line_error(mfilename, sprintf(['No symbolic backend
is available. Start one with\n
', ...
360 ' docker run -d -p 8080:8080 %s\n
', ...
361 'point the LINE_SAGE_URL environment variable at a running service, or\n
', ...
362 'set options.config.symbolic to its URL.
'], SAGE.DOCKER_IMAGES{1}));
366 function r = post(url, endpoint, payload, timeout)
367 % POST One JSON request, with the service's own errors raised here.
369 timeout = SAGE.DEFAULT_TIMEOUT;
371 opts = weboptions(
'MediaType',
'application/json', ...
372 'ContentType',
'json',
'Timeout', max(timeout + 30, 60));
373 r = webwrite([SAGE.trimUrl(url), endpoint], payload, opts);
374 if ~isfield(r,
'status') || ~strcmp(r.status,
'ok')
376 msg = 'unspecified error';
377 if isfield(r, 'code')
380 if isfield(r, 'message')
383 line_error(mfilename, sprintf('line-sage-rest %s failed [%s]: %s', ...
384 endpoint, code, msg));
388 function s = trimUrl(url)
389 s = regexprep(strtrim(url), '/+$', '');
392 function port = freePort()
393 % FREEPORT An ephemeral port nothing
is listening on.
395 % There
is a race between finding the port and docker
binding it;
396 % it
is the same race the JAR and Python clients run, and losing
397 % it fails the start loudly rather than silently sharing a port.
400 candidate = 20000 + randi(20000);
401 [st, ~] = unix(sprintf(...
402 'command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":%d " && echo used', ...
410 port = 20000 + randi(20000);
414 function [Qcell, symbols] = toExpressionMatrix(Q, symbols)
415 % TOEXPRESSIONMATRIX Generator as a cell array of expressions.
417 Qcell = cellfun(@SAGE.exprToChar, Q, 'UniformOutput', false);
419 Qcell = arrayfun(@(v) SAGE.numToChar(v), Q, 'UniformOutput', false);
421 % sym matrix:
char() each entry, keeping ^ which the service
422 % reads as exponentiation.
428 Qcell{i, j} = char(Q(i, j));
431 if nargin < 2 || isempty(symbols)
432 symbols = arrayfun(@
char, symvar(Q),
'UniformOutput',
false);
435 % jsonencode maps a cell matrix to a flat array, so hand it a cell
436 % of row cells, which becomes the nested array the service wants.
437 rows = cell(1, size(Qcell, 1));
438 for i = 1:size(Qcell, 1)
439 rows{i} = Qcell(i, :);
442 if nargin < 2 || isempty(symbols)
445 symbols = SAGE.asCellstr(symbols);
448 function out = toExpressionList(exprs)
449 % TOEXPRESSIONLIST Expressions as a cellstr, whatever came in.
451 out = arrayfun(@
char, exprs(:), 'UniformOutput', false)';
452 elseif isnumeric(exprs)
453 out = arrayfun(@(v) SAGE.numToChar(v), exprs(:), 'UniformOutput', false)';
457 out = cellfun(@SAGE.exprToChar, exprs(:),
'UniformOutput', false)
';
461 function s = exprToChar(e)
465 s = SAGE.numToChar(e);
471 function s = numToChar(v)
472 % NUMTOCHAR Decimal text the service reads as an exact rational.
473 if v == round(v) && abs(v) < 1e15
474 s = sprintf('%d
', round(v));
476 s = sprintf('%.17g
', v);
480 function out = asCellstr(x)
486 out = reshape(x, 1, []);
488 out = num2cell(reshape(x, 1, []));
492 function out = asCellMatrix(x)
493 % ASCELLMATRIX Nested JSON array to a cell matrix.
495 out = cell(numel(x), 0);
497 row = SAGE.asCellstr(x{i});
498 out(i, 1:numel(row)) = row;
505 function out = toSymOrChar(items)
506 % TOSYMORCHAR Expressions as sym when the toolbox is present.
508 % Returning sym where possible keeps every existing caller working
509 % unchanged; without the toolbox the same result is still usable
510 % as text, which is the whole point of the Sage backend.
511 items = SAGE.asCellstr(items);
512 if SAGE.hasSymbolicToolbox()
513 out = sym(zeros(1, numel(items)));
514 for i = 1:numel(items)
515 out(i) = str2sym(items{i});
522 function out = scalarSymOrChar(item)
523 out = SAGE.toSymOrChar({item});
529 function bool = hasSymbolicToolbox()
530 % HASSYMBOLICTOOLBOX True if sym objects can be constructed here.
533 cached = ~isempty(ver('symbolic
')) && exist('str2sym
', 'file
') > 0;