Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# =====================================================================
# owuitools-octave - the code execution sandbox.
#
# This is the only container in the suite that runs code supplied by
# whoever is talking to the model. It therefore gets the strictest
# treatment in docker-compose.yml: network_mode "none", read-only root,
# non-root user, all capabilities dropped.
#
# It has NO network stack at all, so it cannot be given work over HTTP.
# Jobs arrive as files on a small tmpfs volume shared with the API
# container, and results go back the same way. See run.sh.
# =====================================================================
FROM debian:12-slim
# octave-* forge packages cover the four toolboxes people actually reach
# for in general engineering work. They are NOT auto-loaded: run.sh does
# the `pkg load` calls, because a script that says `pkg load signal`
# itself is valid Octave but is not valid MATLAB, and we do not want the
# model learning to write it.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
octave \
octave-signal \
octave-control \
octave-statistics \
octave-optim \
gnuplot-nox \
ghostscript \
fonts-freefont-otf \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Path shim, deliberately outside any working directory so it cannot show
# up as a user artefact or be shadowed by a user file. See shim/print.m.
# Octave never tells gnuplot to fill the legend background, so plot lines
# show through it. See the script for why this is a build-time edit.
COPY patch-legend-opaque.sh /tmp/patch-legend-opaque.sh
RUN sh /tmp/patch-legend-opaque.sh && rm /tmp/patch-legend-opaque.sh
COPY shim/ /opt/owuitools-shim/
# The shims implement MATLAB functions this Octave does not have, on a
# path that sits ahead of everything. Two things have to be true at build
# time and cannot be checked later: that Octave has not since acquired the
# function itself (in which case ours is deleted rather than allowed to
# shadow it), and that each one does what MATLAB documents. The script
# fails the build if a shim is wrong.
COPY check-shims.sh /tmp/check-shims.sh
RUN sh /tmp/check-shims.sh && rm /tmp/check-shims.sh
COPY run.sh /usr/local/bin/run.sh
RUN chmod 0555 /usr/local/bin/run.sh
# Unprivileged, and with a home it can actually write to (a read-only
# root filesystem means HOME must live on the tmpfs).
# Same uid as owuitools-api on purpose. The two containers share one small
# tmpfs for job files, and matching uids let that volume be mode 0700 -
# unreadable to anything else - instead of world-writable. It grants no
# privilege: they are separate containers with separate namespaces, and
# the uid only decides who can touch /jobs.
RUN useradd --uid 10001 --create-home --home-dir /home/runner --shell /usr/sbin/nologin runner
USER 10001:10001
ENV HOME=/tmp/octhome
ENTRYPOINT ["/usr/local/bin/run.sh"]
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
# Guard the MATLAB compatibility shims at image build time.
#
# Two jobs, both of which have to happen before anyone can run code
# against this image rather than after:
#
# 1. DROP any shim whose name Octave has since acquired. The shim
# directory sits ahead of everything on the path, so a stale copy
# would shadow the real implementation - our four-signature `contains`
# would quietly replace a complete one. Octave gaining a function is
# good news; the correct response is to delete ours, not to keep it.
#
# 2. TEST what survives. Each shim is asserted against the behaviour the
# MATLAB documentation specifies - including the edge conventions that
# are the whole reason these are not one-line aliases. A failure fails
# the build: a wrong answer that runs is the one outcome this sandbox
# exists to prevent.
#
# print.m is exempt from (1). It shadows Octave's own print deliberately
# and permanently; see shim/print.m.
set -e
SHIM_DIR=/opt/owuitools-shim
[ -d "$SHIM_DIR" ] || { echo "check-shims: $SHIM_DIR missing"; exit 1; }
for f in "$SHIM_DIR"/*.m; do
name=$(basename "$f" .m)
case "$name" in
print|owuitools_*) continue ;;
esac
# Asked WITHOUT the shim directory on the path, which is where Octave
# starts. The answer is printed rather than returned as an exit code, so
# that an Octave that fails for some unrelated reason cannot be read as
# "this function exists" - an unreadable answer keeps the shim, and the
# behaviour tests below then have the last word.
have=$(octave --no-window-system --norc --quiet \
--eval "printf('%d', exist('$name') > 0)" 2>/dev/null)
case "$have" in
1) echo "check-shims: DROPPING $name - this Octave provides it"
rm -f "$f" ;;
0) echo "check-shims: keeping $name (Octave has no such function)" ;;
*) echo "check-shims: could not test $name (got '$have'), keeping it" ;;
esac
done
echo "check-shims: running the behaviour tests"
octave --no-window-system --norc --quiet --eval "
addpath('$SHIM_DIR', '-begin');
function check(label, cond)
if (~cond)
error('check-shims FAILED: %s', label);
end
printf(' ok %s\n', label);
end
if (exist('contains') > 0)
check('contains char', contains('hello world', 'o w') == true);
check('contains miss', contains('hello', 'zz') == false);
check('contains cell', isequal(contains({'ab','cd'}, 'c'), [false true]));
check('contains any of', contains('abc', {'x','b'}) == true);
check('contains IgnoreCase', contains('ABC', 'b', 'IgnoreCase', true) == true);
end
if (exist('strlength') > 0)
check('strlength char', strlength('abcd') == 4);
check('strlength cell', isequal(strlength({'a','abc'}), [1 3]));
check('strlength empty', strlength('') == 0);
end
if (exist('matches') > 0)
check('matches exact', matches('abc', 'abc') == true);
check('matches not substring', matches('abc', 'ab') == false);
end
if (exist('maxk') > 0)
check('maxk values', isequal(maxk([3 1 4 1 5], 2), [5 4]));
check('maxk row shape', size(maxk([3 1 4], 2), 1) == 1);
[~, i] = maxk([3 1 4 1 5], 2);
check('maxk indices', isequal(i, [5 3]));
check('maxk k over n', numel(maxk([1 2], 9)) == 2);
check('maxk matrix is column-wise', isequal(maxk([1 9; 5 2], 1), [5 9]));
end
if (exist('mink') > 0)
check('mink values', isequal(mink([3 1 4 1 5], 2), [1 1]));
end
if (exist('histcounts') > 0)
check('histcounts counts', isequal(histcounts([1 2 2 3], [1 2 3 4]), [1 2 1]));
check('histcounts last bin closed', isequal(histcounts([4], [1 2 3 4]), [0 0 1]));
check('histcounts half open', isequal(histcounts([2], [1 2 3 4]), [0 1 0]));
check('histcounts drops NaN', sum(histcounts([1 NaN], [1 2])) == 1);
failed = false;
try
histcounts([1 2 3]);
catch
failed = true;
end
check('histcounts refuses auto bins', failed);
end
if (exist('discretize') > 0)
check('discretize bins', isequal(discretize([1 2.5 4], [1 2 3 4]), [1 2 3]));
check('discretize outside is NaN', isnan(discretize(9, [1 2])));
end
if (exist('rmmissing') > 0)
check('rmmissing vector', isequal(rmmissing([1 NaN 3]), [1 3]));
check('rmmissing keeps row shape', size(rmmissing([1 NaN 3]), 1) == 1);
check('rmmissing matrix drops rows', isequal(rmmissing([1 2; NaN 4; 5 6]), [1 2; 5 6]));
failed = false;
try
rmmissing({'a'});
catch
failed = true;
end
check('rmmissing refuses cells', failed);
end
printf('check-shims: all shim behaviour tests pass\n');
"
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Make gnuplot fill the legend box - but only when the legend is inside the axes.
#
# Octave's gnuplot backend emits `set key ...` but never `set key opaque`, and
# gnuplot's default is noopaque - so a legend drawn INSIDE the axes has a
# transparent background and plot lines run straight through the text. On a
# busy plot the legend becomes unreadable, which is exactly when a legend
# matters most. The legend axes already has color [1 1 1]; Octave simply never
# tells gnuplot to fill it.
#
# An OUTSIDE key needs the opposite. gnuplot packs a key below the axes flush
# against the x-axis label - a one-pixel gap, whatever the type size - so the
# fill lands on the label's descenders and erases the tail of every g, y and p
# in it: "from the trigger edge" renders as "from the triaaer edae". Out there
# the key sits on the white margin with no plot lines to hide behind it, so
# the fill buys nothing and costs the label. `noopaque` is also stated rather
# than left to the default, because `set key opaque` set by an earlier axes
# persists through the rest of a multiplot stream.
#
# Done at image build time rather than at runtime because it edits a vendored
# Octave library file: in the Dockerfile it is reproducible, visible in review,
# and cannot be half-applied. It exits 0 when the anchor is absent, so an
# Octave upgrade that moves this code degrades to the old rendering instead of
# failing the build.
set -e
ANCHOR='__do_enhanced_option__ (enhanced, hlgnd));'
ADDED='owuitools: gnuplot defaults to a transparent key'
found=0
for f in /usr/share/octave/*/m/plot/util/private/__gnuplot_draw_axes__.m; do
[ -f "$f" ] || continue
if grep -q "$ADDED" "$f"; then
echo "legend-opaque: already applied to $f"
found=1
continue
fi
n=$(grep -c "$ANCHOR" "$f" || true)
if [ "$n" != "1" ]; then
echo "legend-opaque: anchor appears $n times in $f, skipping"
continue
fi
awk -v anchor="$ANCHOR" -v added="$ADDED" '
{ print }
index($0, anchor) && !done {
done = 1
print " ## " added ", so plot lines show"
print " ## through the legend text. The legend axes is already white."
print " ## Outside the axes the fill has nothing to hide and lands on"
print " ## the x-axis label instead, so there it is turned off - and"
print " ## said out loud, because opaque persists across a multiplot."
print " if (strcmp (inout, \"inside\"))"
print " fputs (plot_stream, \"set key opaque;\\n\");"
print " else"
print " fputs (plot_stream, \"set key noopaque;\\n\");"
print " endif"
}
' "$f" > "$f.owuitools" && mv "$f.owuitools" "$f"
echo "legend-opaque: applied to $f"
found=1
done
[ "$found" = "1" ] || echo "legend-opaque: target not found, skipping"
exit 0
Executable
+288
View File
@@ -0,0 +1,288 @@
#!/bin/bash
# =====================================================================
# Job runner for the Octave sandbox.
#
# WHY FILES AND NOT HTTP
# This container executes code supplied by whoever is talking to the
# model, so it runs with network_mode "none" - there is no network stack
# to serve HTTP on. Work arrives as files on a small tmpfs shared with
# the api container:
#
# /jobs/in/<id>/ directory the api fills with main.m, any extra
# .m files, and a `meta` file carrying a nonce
# /jobs/in/<id>.ready created LAST, so its appearance means the
# directory is complete. Without it the runner
# races the api and executes half-written code.
# /jobs/out/<id>.out result, moved into place atomically
#
# THE NONCE
# The executed script shares this uid and can therefore see /jobs. An
# adversarial review pointed out that this lets a script do more than the
# denial-of-service originally documented: it could WRITE another job's
# result file and hand that user a forged answer. So every result is
# stamped with a per-job nonce that the api generates and checks, and
# `meta` is deleted before Octave starts - the nonce lives only in this
# shell's memory, and the interpreter never has anything to read it from.
# The runner also writes the section markers itself, so a script cannot
# emit one.
#
# WHAT REMAINS, stated plainly: a hostile script can still delete or read
# another queued job, so it can cause a timeout or see someone else's
# source. It cannot escape the container, reach the network, forge a
# result, or read anything of the operator's. Closing the last gap needs a
# per-job container and therefore the docker socket, which would be a far
# worse trade than the problem.
# =====================================================================
set -u
# One line per sandboxed run, with every other component's log in ./logs.
# `exec` rather than a tee per echo, so anything the loop reports - including
# a job that failed in a way nobody anticipated - lands in the file too.
LOG_DIR="${OWUITOOLS_LOG_DIR:-/logs}"
if [ -d "$LOG_DIR" ] && [ -w "$LOG_DIR" ]; then
exec > >(tee -a "$LOG_DIR/octave.log") 2>&1
fi
JOBS=${OWUITOOLS_JOBS_DIR:-/jobs}
DEFAULT_TIMEOUT=${OWUITOOLS_OCTAVE_TIMEOUT:-25}
MAX_TIMEOUT=${OWUITOOLS_OCTAVE_MAX_TIMEOUT:-60}
MAX_OUT=${OWUITOOLS_OCTAVE_MAX_OUTPUT:-200000}
REAP_MINUTES=${OWUITOOLS_OCTAVE_REAP_MINUTES:-10}
mkdir -p "$JOBS/in" "$JOBS/run" "$JOBS/out" "$HOME" 2>/dev/null
# Wrapping the user script in try/catch is what lets us hand the model a
# structured error with a line number instead of raw stderr, which varies
# by Octave version and buries the location.
#
# `message` is emitted LAST and everything after it is the message. An
# earlier version put it first, so an error whose text happened to contain
# a line starting "at:" - entirely possible, Octave quotes source - was
# truncated there and the rest parsed as a garbage stack frame.
DRIVER='
__ws = warning();
warning("off", "all");
try
pkg load signal; pkg load control;
pkg load statistics; pkg load optim;
catch
end
try
graphics_toolkit("gnuplot");
catch
end
try
set(0, "defaultfigurevisible", "off");
catch
end
% Two MATLAB-compatibility preferences, both measured against MATLAB`s
% documented behaviour rather than assumed:
% print_empty_dimensions - Octave prints `x = [](0x0)` where MATLAB
% prints `x = []`, and the model reads that output back to the user.
% save_default_options - `save("data.mat", "x")` wrote an Octave TEXT
% file with a `# Created by Octave` header. A user opening it in
% MATLAB gets nothing. -mat7-binary writes a real MAT-file (verified:
% the header reads "MATLAB 5.0 MAT-file"), which both read.
% The rest of Octave`s --traditional set is deliberately not taken: it
% also turns struct printing off and the terminal bell on.
try
print_empty_dimensions(false);
catch
end
try
save_default_options("-mat7-binary");
catch
end
warning(__ws);
clear __ws;
% Ahead of Octave`s own print. Renders PNGs through the cairo terminal so
% thin lines are anti-aliased instead of dropping pixels; see shim/print.m.
% Suppressed BEFORE the addpath, not after: the warning is emitted at the
% moment the shim joins the path, so silencing it afterwards was one line
% too late. The shadow is deliberate and permanent, and this is the same
% stderr the model reads for its real errors.
warning("off", "Octave:shadowed-function");
try
addpath("/opt/owuitools-shim", "-begin");
catch
end
more off;
% Octave`s own parser, used as a second compatibility checker: it names
% the Octave-only constructs in the file it is about to run - `x += 1`,
% `!=`, `.*=`, a bare newline inside parentheses - with a line number, and
% it is never wrong about its own dialect the way a regex can be.
%
% This was OFF for a long time and the comment here said it was unusable,
% because Octave`s own library is written in the same dialect and the real
% error disappeared under hundreds of warnings about files nobody can
% edit. The missing piece was that every warning names its file: the
% library ones are dropped by path in run.sh, and what is left is the
% user`s code. Turned on LAST, after the pkg loads and the addpath, so
% none of that machinery is parsed with it enabled.
warning("on", "Octave:language-extension");
try
source("main.m");
catch err
fflush(stdout);
fprintf(stderr, "\n__OWUITOOLS_ERR__\n");
fprintf(stderr, "identifier:%s\n", err.identifier);
if (isfield(err, "stack") && numel(err.stack) > 0)
for k = 1:numel(err.stack)
fprintf(stderr, "at:%s:%d\n", err.stack(k).name, err.stack(k).line);
end
end
fprintf(stderr, "message:%s\n", err.message);
fflush(stderr);
exit(3);
end
fflush(stdout);
exit(0);
'
run_job() {
local id="$1" dir="$JOBS/run/$id"
local work timeout rc started ended nonce figure
work="$dir/work"
timeout="$DEFAULT_TIMEOUT"
nonce=""
figure=""
if [ -f "$dir/meta" ]; then
local t
t=$(sed -n 's/^TIMEOUT=\([0-9]\{1,3\}\)$/\1/p' "$dir/meta" | head -1)
if [ -n "$t" ] && [ "$t" -ge 1 ] 2>/dev/null && [ "$t" -le "$MAX_TIMEOUT" ]; then
timeout="$t"
fi
nonce=$(sed -n 's/^NONCE=\([A-Za-z0-9]\{8,64\}\)$/\1/p' "$dir/meta" | head -1)
figure=$(sed -n 's/^FIGURE=\([A-Za-z0-9_.-]\{1,64\}\)$/\1/p' "$dir/meta" | head -1)
fi
mkdir -p "$work"
# Only .m files move into the working directory. Anything else the api
# left there is metadata, and letting it onto the Octave path would be a
# way to smuggle in a file the linter never saw.
local inputs="$dir/__inputs"
: > "$inputs"
for f in "$dir"/*.m; do
[ -e "$f" ] || continue
cp "$f" "$work/"
basename "$f" >> "$inputs"
done
cp "$inputs" "$work/../__inputs.keep" 2>/dev/null
printf '%s' "$DRIVER" > "$work/__driver.m"
# The nonce must not survive into anything the interpreter can read.
rm -f "$dir/meta" "$inputs" 2>/dev/null
started=$(date +%s%3N)
( cd "$work" && timeout -s KILL "$timeout" \
octave --no-window-system --norc --no-history --quiet \
--eval "run('__driver.m')" ) \
> "$work/__stdout" 2> "$work/__stderr"
rc=$?
ended=$(date +%s%3N)
# Files the script itself created: what is in the working directory now,
# minus what we put there. Newline separated, because a filename may
# legitimately contain a comma and a comma-joined list then reads back
# as two files that do not exist.
local artefacts
artefacts=$(cd "$work" && ls -1 2>/dev/null \
| grep -v -x -F -f "$work/../__inputs.keep" \
| grep -v -x -E '__driver\.m|__stdout|__stderr' \
| head -20)
{
echo "NONCE=$nonce"
echo "RC=$rc"
echo "TIMEOUT=$timeout"
echo "DURATION_MS=$((ended - started))"
echo "__OWUITOOLS_ARTEFACTS__"
printf '%s\n' "$artefacts"
echo "__OWUITOOLS_STDOUT__"
head -c "$MAX_OUT" "$work/__stdout" 2>/dev/null
echo
echo "__OWUITOOLS_STDERR__"
# Octave:language-extension is on (see the driver) so the parser tells
# us which Octave-only constructs the USER wrote. Octave`s own library
# is written in the same dialect and would bury that under hundreds of
# lines about files nobody can edit, so those are dropped here, by
# path, before anything is measured against the output cap.
#
# A warning is three parts - the line itself, a "called from", and its
# indented frames - and dropping only the first left the frames behind
# as orphans, which read like a stack trace for an error that never
# happened. The file is matched rather than the spacing because 7.3
# prints "offile" for "of file".
awk '
/^warning: Octave language extension used:.*(\/usr\/share\/octave|\/opt\/owuitools-shim)/ {
skip = 1; next
}
skip && /^warning: called from[ \t]*$/ { next }
skip && /^[ \t]+/ { next }
skip && /^[ \t]*$/ { next }
{ skip = 0; print }
' "$work/__stderr" 2>/dev/null | head -c "$MAX_OUT"
# A figure the caller asked for, base64 on one line. Open WebUI turns a
# tool result that is a data: URI into a file shown to the user and
# never sends the bytes to the model, so this costs no context.
if [ -n "$figure" ] && [ -f "$work/$figure" ]; then
sz=$(stat -c %s "$work/$figure" 2>/dev/null || echo 0)
if [ "$sz" -gt 0 ] && [ "$sz" -le 3000000 ]; then
echo
echo "__OWUITOOLS_FIGURE__"
base64 -w 0 "$work/$figure" 2>/dev/null
echo
fi
fi
} > "$JOBS/out/$id.tmp" 2>/dev/null
mv -f "$JOBS/out/$id.tmp" "$JOBS/out/$id.out" 2>/dev/null
rm -rf "$dir" 2>/dev/null
}
# Anything left over from a previous container start would otherwise wedge
# its caller until timeout.
rm -rf "${JOBS:?}/run"/* 2>/dev/null
reap_counter=0
# `claimed`, not `found`: a job we can SEE but cannot CLAIM - a permissions
# problem on the volume, say - would otherwise spin this loop at full CPU
# forever, because the marker never disappears.
while true; do
claimed=0
for ready in "$JOBS/in"/*.ready; do
[ -e "$ready" ] || continue
id=$(basename "$ready" .ready)
case "$id" in
*[!A-Za-z0-9_-]*) rm -f "$ready" 2>/dev/null; continue ;;
esac
if mv "$JOBS/in/$id" "$JOBS/run/$id" 2>/dev/null; then
rm -f "$ready" 2>/dev/null
claimed=1
# Touched here as well as below: the runner is serial, and a long
# job used to starve the heartbeat and report the container
# unhealthy in exactly the case the healthcheck exists to survive.
touch "$JOBS/.heartbeat" 2>/dev/null
run_job "$id"
else
rm -f "$ready" 2>/dev/null
fi
done
# Nothing else reclaims these. A result the api gave up waiting for, or
# a job directory whose .ready never arrived, would sit on a 64 MB tmpfs
# until the container restarted.
reap_counter=$((reap_counter + 1))
if [ "$reap_counter" -ge 400 ]; then
reap_counter=0
find "$JOBS/out" -maxdepth 1 -type f -mmin "+$REAP_MINUTES" -delete 2>/dev/null
find "$JOBS/in" -maxdepth 1 -mmin "+$REAP_MINUTES" \
\( -type f -o -type d \) ! -path "$JOBS/in" -exec rm -rf {} + 2>/dev/null
fi
touch "$JOBS/.heartbeat" 2>/dev/null
[ "$claimed" -eq 1 ] || sleep 0.15
done
+54
View File
@@ -0,0 +1,54 @@
function tf = contains (str, pat, varargin)
%CONTAINS True if a pattern occurs in a string (MATLAB R2016b+).
%
% One of the owuitools compatibility shims. This Octave has no
% `contains`, so a perfectly ordinary MATLAB line failed here and the
% model spent calls rediscovering `strfind`. The rule for everything in
% this directory: implement the documented behaviour for the forms that
% are actually written, and raise a clear error for the rest. A shim
% that quietly does something *close to* MATLAB is worse than one that
% is not there, because the run then certifies the wrong answer.
%
% Text here means a char row vector or a cell array of them - this
% Octave has no string type, and pretending otherwise is exactly the
% quiet difference this file exists to avoid.
if (nargin < 2)
error ('contains: not enough input arguments');
end
ignore_case = false;
if (numel (varargin) == 2 && ischar (varargin{1}) ...
&& strcmpi (varargin{1}, 'IgnoreCase'))
ignore_case = logical (varargin{2});
elseif (~isempty (varargin))
error ('contains: the only supported option is ''IgnoreCase''');
end
strs = owuitools_text_cell (str, 'contains', 'str');
pats = owuitools_text_cell (pat, 'contains', 'pat');
if (isempty (pats))
error ('contains: pat must not be empty');
end
tf = false (size (strs));
for i = 1:numel (strs)
s = strs{i};
for j = 1:numel (pats)
p = pats{j};
if (ignore_case)
hit = ~isempty (strfind (lower (s), lower (p)));
else
hit = ~isempty (strfind (s, p));
end
if (hit)
tf(i) = true;
break;
end
end
end
if (ischar (str))
tf = tf(1);
end
end
+38
View File
@@ -0,0 +1,38 @@
function bins = discretize (x, edges, varargin)
%DISCRETIZE Which bin each value falls in, for explicit edges (R2015a+).
%
% An owuitools compatibility shim; see contains.m for the policy, and
% histcounts.m for why explicit edges only and for the edge convention,
% which this shares: the last bin is closed, everything outside the
% edges is NaN, and so is NaN itself.
if (nargin < 2)
error ('discretize: not enough input arguments');
end
if (~isnumeric (edges) || isscalar (edges))
error (['discretize: this sandbox supports discretize(x, edges) with explicit ' ...
'edges only - MATLAB''s automatic binning is not reproduced here.']);
end
if (~isempty (varargin))
error ('discretize: this shim takes only (x, edges) - no categorical or option arguments');
end
if (~isnumeric (x) && ~islogical (x))
error ('discretize: x must be numeric');
end
edges = edges(:).';
if (numel (edges) < 2 || any (diff (edges) <= 0))
error ('discretize: edges must be a vector of at least two increasing values');
end
v = double (x);
bins = nan (size (v));
last = numel (edges) - 1;
for i = 1:last
if (i < last)
in = v >= edges(i) & v < edges(i + 1);
else
in = v >= edges(i) & v <= edges(i + 1);
end
bins(in) = i;
end
end
+47
View File
@@ -0,0 +1,47 @@
function [n, edges] = histcounts (x, edges, varargin)
%HISTCOUNTS Bin counts for explicit edges (MATLAB R2014b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
%
% Explicit edges only, and that limit is the whole point: MATLAB's
% automatic binning picks the number of bins by a documented but
% non-trivial rule, and a shim that guessed a different one would hand
% back a different histogram without anyone noticing. Called without
% edges, this says so instead.
%
% Edge convention, which is where histcounts and the older `histc`
% differ and why this is not a one-line alias: bin i is
% [edges(i), edges(i+1)) and the LAST bin is closed, so a value exactly
% on the final edge lands in the last bin rather than in one of its own.
if (nargin < 1)
error ('histcounts: not enough input arguments');
end
if (nargin < 2 || ~isnumeric (edges) || isscalar (edges))
error (['histcounts: this sandbox supports histcounts(x, edges) with explicit ' ...
'edges only. MATLAB''s automatic bin selection is not reproduced here - ' ...
'choose the edges, e.g. histcounts(x, linspace(min(x), max(x), 11)), and ' ...
'keep whatever form you want in the code you hand over.']);
end
if (~isempty (varargin))
error ('histcounts: this shim takes only (x, edges) - no name/value options');
end
if (~isnumeric (x) && ~islogical (x))
error ('histcounts: x must be numeric');
end
edges = edges(:).';
if (numel (edges) < 2 || any (diff (edges) <= 0))
error ('histcounts: edges must be a vector of at least two increasing values');
end
v = double (x(:));
v = v(~isnan (v));
n = zeros (1, numel (edges) - 1);
for i = 1:numel (n)
if (i < numel (n))
n(i) = sum (v >= edges(i) & v < edges(i + 1));
else
n(i) = sum (v >= edges(i) & v <= edges(i + 1));
end
end
end
+41
View File
@@ -0,0 +1,41 @@
function tf = matches (str, pat, varargin)
%MATCHES True where text equals a pattern exactly (MATLAB R2019b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
% Unlike `contains` this is whole-string equality, which is the point
% of it - the two are easy to confuse and MATLAB keeps them apart.
if (nargin < 2)
error ('matches: not enough input arguments');
end
ignore_case = false;
if (numel (varargin) == 2 && ischar (varargin{1}) ...
&& strcmpi (varargin{1}, 'IgnoreCase'))
ignore_case = logical (varargin{2});
elseif (~isempty (varargin))
error ('matches: the only supported option is ''IgnoreCase''');
end
strs = owuitools_text_cell (str, 'matches', 'str');
pats = owuitools_text_cell (pat, 'matches', 'pat');
tf = false (size (strs));
for i = 1:numel (strs)
for j = 1:numel (pats)
if (ignore_case)
hit = strcmpi (strs{i}, pats{j});
else
hit = strcmp (strs{i}, pats{j});
end
if (hit)
tf(i) = true;
break;
end
end
end
if (ischar (str))
tf = tf(1);
end
end
+13
View File
@@ -0,0 +1,13 @@
function [v, idx] = maxk (x, k, varargin)
%MAXK The k largest elements, in descending order (MATLAB R2017b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
% Column-wise for a matrix, like MATLAB and like `sort`. NaNs go last,
% which is also what MATLAB does. `k` larger than the data returns
% everything there is rather than erroring, again matching MATLAB.
if (nargin < 2)
error ('maxk: not enough input arguments');
end
[v, idx] = owuitools_topk (x, k, 'descend', 'maxk', varargin{:});
end
+10
View File
@@ -0,0 +1,10 @@
function [v, idx] = mink (x, k, varargin)
%MINK The k smallest elements, in ascending order (MATLAB R2017b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
if (nargin < 2)
error ('mink: not enough input arguments');
end
[v, idx] = owuitools_topk (x, k, 'ascend', 'mink', varargin{:});
end
+25
View File
@@ -0,0 +1,25 @@
function c = owuitools_text_cell (value, fname, argname)
%OWUITOOLS_TEXT_CELL Normalise char / cellstr input for the shims.
%
% Shared by the text shims in this directory. Returns a cell array of
% char rows, and refuses anything else by name so the message reads
% like the function the caller actually wrote.
%
% The name is prefixed rather than generic because this directory sits
% ahead of everything on the path: a file called `text_cell.m` here
% would shadow anyone's own helper of that name, in their own script,
% with no way for them to see why.
if (ischar (value))
if (ndims (value) > 2 || (~isempty (value) && rows (value) > 1))
error ('%s: %s must be a single row of characters, not a char matrix', fname, argname);
end
c = {value};
elseif (iscellstr (value))
c = value;
elseif (iscell (value))
error ('%s: %s must be text - a char row or a cell array of char rows', fname, argname);
else
error ('%s: %s must be text, not %s', fname, argname, class (value));
end
end
+30
View File
@@ -0,0 +1,30 @@
function [v, idx] = owuitools_topk (x, k, direction, fname, varargin)
%OWUITOOLS_TOPK Shared body of the maxk/mink shims.
if (~isempty (varargin))
error ('%s: this shim takes only (x, k) - no dimension or option arguments', fname);
end
if (~isnumeric (x) && ~islogical (x))
error ('%s: x must be numeric', fname);
end
if (~isnumeric (k) || ~isscalar (k) || k < 0 || k ~= fix (k))
error ('%s: k must be a non-negative integer', fname);
end
if (isvector (x) || isempty (x))
[sorted, order] = sort (x(:), direction);
n = min (k, numel (sorted));
v = sorted(1:n);
idx = order(1:n);
if (size (x, 1) == 1 && ~isempty (x)) % a row in, a row out
v = v.';
idx = idx.';
end
return;
end
[sorted, order] = sort (x, 1, direction);
n = min (k, size (sorted, 1));
v = sorted(1:n, :);
idx = order(1:n, :);
end
+278
View File
@@ -0,0 +1,278 @@
function varargout = print (varargin)
%PRINT Transparent shim: render PNGs through gnuplot's cairo terminal.
%
% The model writes ordinary MATLAB - print(f,'plot.png','-dpng') - and must
% keep writing ordinary MATLAB, because whatever it writes is what the user
% runs. But gnuplot's plain `png` terminal does not anti-alias, and draws
% every line as a 1-pixel stroke regardless of LineWidth. On a steep curve
% that needs several vertical pixels per column it leaves gaps, so a 4 Hz
% sine came out as a field of disconnected dashes rather than a wave.
%
% `pngcairo` anti-aliases and renders the same LineWidth as a continuous
% stroke. Swapping it in here means the picture is right without the code
% being wrong: -dpngcairo is Octave-only and would fail in MATLAB, so it
% must never appear in anything the model writes.
%
% The layout goes the same way. Canvas size, type size and where the key
% sits are properties of this renderer, not of the analysis, so they are
% set here rather than in lines the model would have to remember to write.
% Three fixes, each explained at its helper below:
%
% __owuitools_canvas__ a big canvas WITHOUT print's font scaling
% __owuitools_type__ label type the size gnuplot reserves for
% __owuitools_legends_below__ the key under the axes, not over the data
%
% Every one of them is undone before this function returns: the figure is
% handed back exactly as the script left it, because the script may draw
% into it again and print it a second time.
%
% This file lives outside the working directory and is put on the path
% ahead of Octave's own print, so the user's files are untouched.
args = varargin;
has_device = false;
has_size = false;
has_resolution = false;
wants_png = false;
% The layout work below is about pixels, so it covers every picture the
% tool can hand back - .png, .jpg, .gif - while the cairo swap is about
% one gnuplot terminal and stays with PNG alone.
raster_ext = {'.png', '.jpg', '.jpeg', '.gif'};
raster_dev = {'-dpng', '-djpg', '-djpeg', '-dgif'};
wants_raster = false;
for k = 1:numel (args)
if (ischar (args{k}))
a = args{k};
if (strncmpi (a, '-S', 2))
has_size = true;
elseif (strncmpi (a, '-r', 2) && numel (a) > 2)
has_resolution = true;
elseif (strncmpi (a, '-d', 2) && ! strncmpi (a, '-debug', 6))
has_device = true;
if (any (strcmpi (a, raster_dev)))
wants_raster = true;
end
if (strcmpi (a, '-dpng'))
args{k} = '-dpngcairo';
wants_png = true;
end
elseif (a(1) != '-')
[~, ~, ext] = fileparts (a);
if (any (strcmpi (ext, raster_ext)))
wants_raster = true;
wants_png = wants_png || strcmpi (ext, '.png');
end
end
end
end
% print() also infers the device from the file extension, so a bare
% print(f,'plot.png') has to be caught too.
if (wants_png && ! has_device)
args{end+1} = '-dpngcairo';
end
% Paper inches become pixels at the print resolution, which defaults to
% 150 dpi: the JPEG terminal took the 1100 pt canvas set below and made a
% 2292 px image of it, with 10 pt type marooned in the middle. pngcairo
% ignores the setting, so pinning it to 72 costs nothing there and makes
% one inch mean one hundred points wherever it is read.
if (wants_raster && ! has_size && ! has_resolution)
args{end+1} = '-r72';
end
shim_dir = fileparts (mfilename ('fullpath'));
rmpath (shim_dir);
unwind_protect
undo = {};
if (wants_raster)
fig = __owuitools_figure__ (args);
undo = [__owuitools_canvas__(fig, has_size), ...
__owuitools_type__(fig), ...
__owuitools_legends_below__(fig)];
end
unwind_protect
if (nargout > 0)
[varargout{1:nargout}] = print (args{:});
else
print (args{:});
end
unwind_protect_cleanup
for k = numel (undo):-1:1
try
set (undo{k}{1}, undo{k}{2}, undo{k}{3});
catch
end
end
end_unwind_protect
unwind_protect_cleanup
addpath (shim_dir, '-begin');
end_unwind_protect
end
function fig = __owuitools_figure__ (args)
% print(f,...) names its figure; print('plot.png',...) means the current one.
fig = [];
try
for k = 1:numel (args)
if (! ischar (args{k}) && isscalar (args{k}) && ishghandle (args{k}))
fig = ancestor (args{k}, 'figure');
return;
end
end
if (! isempty (get (0, 'currentfigure')))
fig = gcf ();
end
catch
fig = [];
end
end
function undo = __owuitools_canvas__ (fig, has_size)
%__OWUITOOLS_CANVAS__ A bigger picture without doubling the type with it.
%
% -S is the obvious way to ask for a 1100 px canvas and the wrong one.
% print.m reads it as "a six-inch figure blown up to 1100 px" and scales
% every font in the figure by
%
% width / 6 / screenpixelsperinch (72 here, so width/432)
%
% which at the old -S900,675 was 2.08x: the 10 pt axes font was drawn at
% 16.7 pt and the 11 pt title at 18.3 pt on a canvas only 900 px wide.
% That is what pushed titles off the right edge, sat axis labels on top
% of tick labels, and ran bar-chart category names into each other. A
% bigger -S never helped, because the type grew with the canvas.
%
% Sizing by paper instead takes the other branch of print.m, where the
% canvas is paperposition x 72 and the scale factor stays 1 - so the type
% keeps the size Octave gave it and a bigger canvas buys real room.
%
% The shape follows the figure: a script that made a wide figure gets a
% wide PNG. Only the width is fixed, and only when the caller did not ask
% for a size itself.
undo = {};
if (has_size || isempty (fig))
return;
end
try
width = 1100;
pos = get (fig, 'position');
aspect = 0.75;
if (numel (pos) == 4 && pos(3) > 0 && pos(4) > 0)
aspect = min (1.5, max (0.4, pos(4) / pos(3)));
end
undo = {{fig, 'paperunits', get(fig, 'paperunits')}, ...
{fig, 'paperposition', get(fig, 'paperposition')}, ...
{fig, 'paperpositionmode', get(fig, 'paperpositionmode')}};
set (fig, 'paperunits', 'inches');
set (fig, 'paperposition', [0, 0, width/72, round(width*aspect)/72]);
set (fig, 'paperpositionmode', 'manual');
catch
end
end
function undo = __owuitools_type__ (fig)
%__OWUITOOLS_TYPE__ Axis labels the size gnuplot reserved room for.
%
% gnuplot lays a plot out in character units of the TERMINAL font, and
% Octave sets that terminal font from the axes font size - 10 pt - while
% drawing axis labels at 11, because labelfontsizemultiplier is 1.1. The
% gap is small and the result is not: the space reserved below the tick
% labels is a 10 pt line, the xlabel drawn into it is an 11 pt line, and
% it lands on the tick row. Same story on the left for the ylabel.
%
% Dropping the label multiplier to 1 makes the two agree exactly, which
% is the whole fix - verified against gnuplot directly: same plot, same
% key, xlabel at 11 pt overlaps the ticks and at 10 pt clears them.
%
% The title keeps its 1.1 and its bold weight. It sits in the top margin
% with nothing under it to collide with, and it is the one piece of text
% a reader should find first.
%
% With that tie in place the size can be chosen on its merits, and 10 pt
% - Octave's default, for a 560 px figure on a screen - is too small on a
% 1100 px canvas that a chat window then shows at 700. 12 pt is about
% where it reads at that width and still leaves six category names room
% side by side. A script that set a font size itself keeps it: that is
% what fontsizemode records.
undo = {};
if (isempty (fig))
return;
end
try
ax = findall (fig, 'type', 'axes');
for k = 1:numel (ax)
if (strcmp (get (ax(k), 'tag'), 'legend'))
continue;
end
undo{end+1} = {ax(k), 'labelfontsizemultiplier', ...
get(ax(k), 'labelfontsizemultiplier')};
set (ax(k), 'labelfontsizemultiplier', 1);
if (strcmp (get (ax(k), 'fontsizemode'), 'auto'))
undo{end+1} = {ax(k), 'fontsize', get(ax(k), 'fontsize')};
set (ax(k), 'fontsize', 12);
end
end
catch
end
end
function undo = __owuitools_legends_below__ (fig)
%__OWUITOOLS_LEGENDS_BELOW__ The key under the axes, not over the data.
%
% A legend inside the axes sits on top of what it is labelling: the key
% is opaque here (see patch-legend-opaque.sh, and transparent is worse -
% the lines then run through the text), so it hides the curves under it.
% 'best' does not help either; this backend does not implement it and
% warns, and every legend lands top-right regardless of what was asked.
%
% Below the axes nothing can collide with the data, because gnuplot
% reserves the space for an outside key and shrinks the plot to fit it.
%
% The frame comes off with the move. gnuplot packs an outside key flush
% against the x-axis label - a one-pixel gap, whatever the type size or
% the label's own offset - so the box's top edge is drawn through the
% descenders of that label and reads as an underline under it. Out on
% the white margin, with no plot lines behind the text, the frame was
% only decoration; without it the key is a caption under the plot.
% (The fill is handled one layer down: patch-legend-opaque.sh keeps an
% outside key transparent, because an opaque one erased those same
% descenders outright - "the trigger edge" printed as "the triaaer edae".)
%
% Two legends are left where they are. One a script deliberately put
% outside already - 'eastoutside' - because that is a layout decision
% rather than the default falling where it falls. And every legend in a
% figure that has more than one plot in it: a subplot grid has no margin
% under each plot to give away, only the gap before the next one's title,
% and a key dropped into that gap lands on both. Inside is where they fit
% there, and inside is where the opaque fill still applies.
undo = {};
if (isempty (fig))
return;
end
try
h = findall (fig, 'type', 'axes');
tags = get (h, 'tag');
if (! iscell (tags))
tags = {tags};
end
if (sum (! ismember (tags, {'legend', 'colorbar'})) != 1)
return;
end
for k = 1:numel (h)
if (! strcmp (get (h(k), 'tag'), 'legend'))
continue;
end
loc = get (h(k), 'location');
if (! ischar (loc) || ! isempty (strfind (lower (loc), 'outside')))
continue;
end
undo{end+1} = {h(k), 'location', loc};
set (h(k), 'location', 'southoutside');
undo{end+1} = {h(k), 'box', get(h(k), 'box')};
set (h(k), 'box', 'off');
end
catch
end
end
+35
View File
@@ -0,0 +1,35 @@
function [out, removed] = rmmissing (x, varargin)
%RMMISSING Drop missing values (MATLAB R2016b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
%
% Numeric and logical data only, where "missing" means NaN. MATLAB also
% accepts tables, categoricals, strings and datetimes, where missing
% means something different in each; none of those types exist here, so
% this refuses them rather than inventing an answer. For a vector the
% missing elements go; for a matrix the whole ROW goes, as in MATLAB.
if (nargin < 1)
error ('rmmissing: not enough input arguments');
end
if (~isempty (varargin))
error ('rmmissing: this shim takes only (x) - no dimension or option arguments');
end
if (~isnumeric (x) && ~islogical (x))
error (['rmmissing: this sandbox supports numeric and logical input only, not %s - ' ...
'MATLAB''s table, string and categorical types do not exist here'], class (x));
end
if (isvector (x) || isempty (x))
removed = isnan (double (x(:)));
out = x(~removed);
if (size (x, 1) == 1 && ~isempty (x))
out = reshape (out, 1, []);
end
removed = reshape (removed, size (x));
return;
end
removed = any (isnan (double (x)), 2);
out = x(~removed, :);
end
+21
View File
@@ -0,0 +1,21 @@
function n = strlength (s)
%STRLENGTH Number of characters in each element of text (MATLAB R2016b+).
%
% An owuitools compatibility shim; see contains.m for the policy.
% MATLAB returns a double array shaped like its input, and 0 for the
% empty string. With char input it counts the characters, which is what
% `numel` does here - the difference only shows up for cell input.
if (nargin < 1)
error ('strlength: not enough input arguments');
end
if (ischar (s))
n = numel (s);
return;
end
c = owuitools_text_cell (s, 'strlength', 'str');
n = zeros (size (c));
for k = 1:numel (c)
n(k) = numel (c{k});
end
end