LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
SAGE.m
1classdef SAGE
2 % SAGE MATLAB-to-SageMath bridge for symbolic analysis.
3 %
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.
8 %
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.
14 %
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.
21 %
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.
25 %
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.
29 %
30 % Copyright (c) 2012-2026, Imperial College London
31 % All rights reserved.
32
33 properties (Constant)
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
39 end
40
41 methods (Static)
42
43 function url = resolve(requested)
44 % RESOLVE Base URL of a usable service, or '' if there is none.
45 %
46 % REQUESTED is '' or 'auto' to search, a URL to use a specific
47 % service, 'none' to disable the backend, or an image name.
48 persistent cachedUrl
49 if nargin < 1 || isempty(requested)
50 requested = 'auto';
51 end
52 requested = strtrim(requested);
53 if any(strcmpi(requested, {'none', 'off'}))
54 url = '';
55 return
56 end
57 if strncmpi(requested, 'http://', 7) || strncmpi(requested, 'https://', 8)
58 url = requested;
59 if ~SAGE.isReachable(url)
60 url = '';
61 end
62 return
63 end
64
65 env = getenv('LINE_SAGE_URL');
66 if ~isempty(env) && SAGE.isReachable(env)
67 url = env;
68 return
69 end
70
71 if ~isempty(cachedUrl) && SAGE.isReachable(cachedUrl)
72 url = cachedUrl;
73 return
74 end
75
76 for p = SAGE.PROBE_PORTS
77 candidate = sprintf('http://localhost:%d', p);
78 if SAGE.isSageService(candidate)
79 url = candidate;
80 cachedUrl = url;
81 return
82 end
83 end
84
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();
91 else
92 image = requested;
93 end
94 if isempty(image)
95 url = '';
96 return
97 end
98 url = SAGE.startContainer(image);
99 cachedUrl = url;
100 end
101
102 function bool = isAvailable(requested)
103 % ISAVAILABLE True if a symbolic backend can be resolved.
104 if nargin < 1
105 requested = 'auto';
106 end
107 bool = ~isempty(SAGE.resolve(requested));
108 end
109
110 function img = getDockerImage()
111 % GETDOCKERIMAGE First locally present image tag, or '' if none.
112 img = '';
113 if ispc
114 return
115 end
116 if unix('docker info >/dev/null 2>&1') ~= 0
117 return
118 end
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};
123 return
124 end
125 end
126 end
127
128 function url = startContainer(image)
129 % STARTCONTAINER Run the service and wait for it to answer.
130 %
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.
136 url = '';
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']);
141 if st ~= 0
142 line_warning(mfilename, 'Could not start %s: %s\n', image, strtrim(out));
143 return
144 end
145 candidate = sprintf('http://localhost:%d', port);
146 deadline = tic;
147 while toc(deadline) < SAGE.STARTUP_TIMEOUT
148 if SAGE.isReachable(candidate)
149 url = candidate;
150 return
151 end
152 pause(0.5);
153 end
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);
157 end
158
159 function stopContainer()
160 % STOPCONTAINER Stop every container this machine started here.
161 if ispc
162 return
163 end
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']);
166 end
167
168 function bool = isReachable(url)
169 % ISREACHABLE True if the service answers a health probe.
170 bool = false;
171 try
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');
175 catch
176 end
177 end
178
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.
182 bool = false;
183 try
184 opts = weboptions('Timeout', 5, 'ContentType', 'json');
185 r = webread([SAGE.trimUrl(url), '/api/v1/info'], opts);
186 bool = isfield(r, 'sage_version');
187 catch
188 end
189 end
190
191 % ---------------------------------------------------------------
192 % Operations
193 % ---------------------------------------------------------------
194
195 function [pi, num, den, nConnComp, connComp] = solveCTMC(Q, symbols, url, timeout)
196 % SOLVECTMC Symbolic stationary distribution, pi*Q = 0, sum(pi) = 1.
197 %
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();
203 end
204 if nargin < 4
205 timeout = SAGE.DEFAULT_TIMEOUT;
206 end
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});
214 if iscell(den)
215 den = den{1};
216 end
217 nConnComp = double(r.nConnComp);
218 connComp = double(r.connComp(:))';
219 end
220
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).
223 %
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();
228 end
229 if nargin < 6
230 timeout = SAGE.DEFAULT_TIMEOUT;
231 end
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);
237 end
238 r = SAGE.post(url, '/api/v1/ctmc/sensitivity', payload, timeout);
239 dpi = SAGE.toSymOrChar(r.dpi);
240 pi = SAGE.toSymOrChar(r.pi);
241 S = '';
242 SS = '';
243 if isfield(r, 'S')
244 S = SAGE.scalarSymOrChar(r.S);
245 end
246 if isfield(r, 'SS')
247 SS = SAGE.scalarSymOrChar(r.SS);
248 end
249 end
250
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)
255 form = 'cancel';
256 end
257 if nargin < 3 || isempty(url)
258 url = SAGE.require();
259 end
260 if nargin < 4
261 timeout = SAGE.DEFAULT_TIMEOUT;
262 end
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);
267 end
268
269 function out = diff(exprs, variable, order, url, timeout)
270 % DIFF Differentiate expressions with respect to VARIABLE.
271 if nargin < 3 || isempty(order)
272 order = 1;
273 end
274 if nargin < 4 || isempty(url)
275 url = SAGE.require();
276 end
277 if nargin < 5
278 timeout = SAGE.DEFAULT_TIMEOUT;
279 end
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);
284 end
285
286 function [values, exact] = eval(exprs, assignment, url, timeout)
287 % EVAL Substitute values for symbols and evaluate.
288 %
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();
295 end
296 if nargin < 4
297 timeout = SAGE.DEFAULT_TIMEOUT;
298 end
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}));
304 end
305 payload = struct('exprs', {SAGE.toExpressionList(exprs)}, ...
306 'values', values_in, 'timeout_s', timeout);
307 r = SAGE.post(url, '/api/v1/eval', payload, timeout);
308 raw = r.values;
309 if iscell(raw)
310 values = nan(1, numel(raw));
311 for i = 1:numel(raw)
312 if ~isempty(raw{i}) && isnumeric(raw{i})
313 values(i) = raw{i};
314 end
315 end
316 else
317 values = double(raw(:))';
318 end
319 exact = SAGE.asCellstr(r.exact);
320 end
321
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'};
326 end
327 if nargin < 4 || isempty(url)
328 url = SAGE.require();
329 end
330 if nargin < 5
331 timeout = SAGE.DEFAULT_TIMEOUT;
332 end
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);
337 jacobian = {};
338 latexForm = {};
339 equilibria = {};
340 if isfield(r, 'jacobian')
341 jacobian = SAGE.asCellMatrix(r.jacobian);
342 end
343 if isfield(r, 'latex')
344 latexForm = SAGE.asCellstr(r.latex);
345 end
346 if isfield(r, 'equilibria')
347 equilibria = r.equilibria;
348 end
349 end
350
351 % ---------------------------------------------------------------
352 % Plumbing
353 % ---------------------------------------------------------------
354
355 function url = require()
356 % REQUIRE Resolve a backend or error with how to get one.
357 url = SAGE.resolve('auto');
358 if isempty(url)
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}));
363 end
364 end
365
366 function r = post(url, endpoint, payload, timeout)
367 % POST One JSON request, with the service's own errors raised here.
368 if nargin < 4
369 timeout = SAGE.DEFAULT_TIMEOUT;
370 end
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')
375 code = 'error';
376 msg = 'unspecified error';
377 if isfield(r, 'code')
378 code = r.code;
379 end
380 if isfield(r, 'message')
381 msg = r.message;
382 end
383 line_error(mfilename, sprintf('line-sage-rest %s failed [%s]: %s', ...
384 endpoint, code, msg));
385 end
386 end
387
388 function s = trimUrl(url)
389 s = regexprep(strtrim(url), '/+$', '');
390 end
391
392 function port = freePort()
393 % FREEPORT An ephemeral port nothing is listening on.
394 %
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.
398 port = 0;
399 for attempt = 1:20
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', ...
403 candidate));
404 if st ~= 0
405 port = candidate;
406 return
407 end
408 end
409 if port == 0
410 port = 20000 + randi(20000);
411 end
412 end
413
414 function [Qcell, symbols] = toExpressionMatrix(Q, symbols)
415 % TOEXPRESSIONMATRIX Generator as a cell array of expressions.
416 if iscell(Q)
417 Qcell = cellfun(@SAGE.exprToChar, Q, 'UniformOutput', false);
418 elseif isnumeric(Q)
419 Qcell = arrayfun(@(v) SAGE.numToChar(v), Q, 'UniformOutput', false);
420 else
421 % sym matrix: char() each entry, keeping ^ which the service
422 % reads as exponentiation.
423 n = size(Q, 1);
424 m = size(Q, 2);
425 Qcell = cell(n, m);
426 for i = 1:n
427 for j = 1:m
428 Qcell{i, j} = char(Q(i, j));
429 end
430 end
431 if nargin < 2 || isempty(symbols)
432 symbols = arrayfun(@char, symvar(Q), 'UniformOutput', false);
433 end
434 end
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, :);
440 end
441 Qcell = rows;
442 if nargin < 2 || isempty(symbols)
443 symbols = {};
444 end
445 symbols = SAGE.asCellstr(symbols);
446 end
447
448 function out = toExpressionList(exprs)
449 % TOEXPRESSIONLIST Expressions as a cellstr, whatever came in.
450 if isa(exprs, 'sym')
451 out = arrayfun(@char, exprs(:), 'UniformOutput', false)';
452 elseif isnumeric(exprs)
453 out = arrayfun(@(v) SAGE.numToChar(v), exprs(:), 'UniformOutput', false)';
454 elseif ischar(exprs)
455 out = {exprs};
456 else
457 out = cellfun(@SAGE.exprToChar, exprs(:), 'UniformOutput', false)';
458 end
459 end
460
461 function s = exprToChar(e)
462 if ischar(e)
463 s = e;
464 elseif isnumeric(e)
465 s = SAGE.numToChar(e);
466 else
467 s = char(e);
468 end
469 end
470
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));
475 else
476 s = sprintf('%.17g', v);
477 end
478 end
479
480 function out = asCellstr(x)
481 if isempty(x)
482 out = {};
483 elseif ischar(x)
484 out = {x};
485 elseif iscell(x)
486 out = reshape(x, 1, []);
487 else
488 out = num2cell(reshape(x, 1, []));
489 end
490 end
491
492 function out = asCellMatrix(x)
493 % ASCELLMATRIX Nested JSON array to a cell matrix.
494 if iscell(x)
495 out = cell(numel(x), 0);
496 for i = 1:numel(x)
497 row = SAGE.asCellstr(x{i});
498 out(i, 1:numel(row)) = row;
499 end
500 else
501 out = x;
502 end
503 end
504
505 function out = toSymOrChar(items)
506 % TOSYMORCHAR Expressions as sym when the toolbox is present.
507 %
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});
516 end
517 else
518 out = items;
519 end
520 end
521
522 function out = scalarSymOrChar(item)
523 out = SAGE.toSymOrChar({item});
524 if iscell(out)
525 out = out{1};
526 end
527 end
528
529 function bool = hasSymbolicToolbox()
530 % HASSYMBOLICTOOLBOX True if sym objects can be constructed here.
531 persistent cached
532 if isempty(cached)
533 cached = ~isempty(ver('symbolic')) && exist('str2sym', 'file') > 0;
534 end
535 bool = cached;
536 end
537 end
538end