Skip to content

Translate numeric code between .m and NumPy, and check that it still computes the same thing

dualect is a command-line tool that translates scripts between .m files and Python/NumPy in both directions. It translates only what its tested rules cover, marks everything else as untranslated, and runs both versions to compare every variable.

Install and try it Source on GitHub

The problem

A lot of numerical code is written for Octave or MATLAB, and teams often want it in Python with NumPy, or need to move code the other way. The two languages look alike but differ in ways that change results without raising an error:

  • Indexing starts at 1 and ranges include their end in .m code; NumPy starts at 0 and slices exclude their end.
  • * is a matrix product in .m code and an element-wise product in NumPy, where the matrix product is @.
  • linspace defaults to 100 points in .m code and 50 in NumPy.
  • .m arrays are copied on assignment; NumPy arrays can share memory through views.

Older translators are mostly unmaintained, go in one direction only, and produce Python that imitates the source language instead of plain NumPy. A general-purpose language model (LLM) often translates correctly, but nothing checks its output, and it re-derives the same rules on every request.

dualect takes a narrower approach. It keeps a rule for each construct or built-in function: a small folder that explains how the two languages differ and holds example pairs that are run in both. Rules that are reliable have a deterministic translator. The rest are handed to an LLM as focused context. Whichever way a translation is produced, dualect runs the source and the result and compares their outputs.

An example

This is corpus/ode_rk4.m from the repository, a fourth-order Runge-Kutta integration of a damped oscillator, followed by the Python file dualect writes for it with dualect translate corpus/ode_rk4.m -o ode_rk4.py. Neither listing has been edited.

Input: ode_rk4.m
% Damped harmonic oscillator x'' + 2 zeta w0 x' + w0^2 x = 0, integrated with
% the classic fourth-order Runge-Kutta method and compared with the exact
% solution.
w0 = 2*pi;          % natural frequency [rad/s]
zeta = 0.1;         % damping ratio
f = @(t, y) [y(2); -2*zeta*w0*y(2) - w0^2*y(1)];

t_end = 3;
h = 0.01;
t = 0:h:t_end;
n = numel(t);
y = zeros(2, n);
y(:, 1) = [1; 0];   % x(0) = 1, x'(0) = 0

for k = 1:n-1
  k1 = f(t(k), y(:, k));
  k2 = f(t(k) + h/2, y(:, k) + h/2*k1);
  k3 = f(t(k) + h/2, y(:, k) + h/2*k2);
  k4 = f(t(k) + h, y(:, k) + h*k3);
  y(:, k+1) = y(:, k) + h/6*(k1 + 2*k2 + 2*k3 + k4);
end

wd = w0*sqrt(1 - zeta^2);
x_exact = exp(-zeta*w0*t) .* (cos(wd*t) + zeta*w0/wd*sin(wd*t));
err = max(abs(y(1, :) - x_exact));
energy = 0.5*y(2, :).^2 + 0.5*w0^2*y(1, :).^2;
decay = energy(end) / energy(1);
Output: ode_rk4.py, as written by dualect
# Translated from ode_rk4.m by dualect; verified against the .m source.
import numpy as np

# Damped harmonic oscillator x'' + 2 zeta w0 x' + w0^2 x = 0, integrated with
# the classic fourth-order Runge-Kutta method and compared with the exact
# solution.
w0 = 2 * np.pi  # natural frequency [rad/s]
zeta = 0.1  # damping ratio
f = lambda t, y, zeta=zeta, w0=w0: np.array(
    [y[1], -2 * zeta * w0 * y[1] - w0**2 * y[0]]
)

t_end = 3
h = 0.01
t = np.linspace(0, t_end, 301)
n = t.size
y = np.zeros((2, n))
y[:, 0] = np.array([1, 0])  # x(0) = 1, x'(0) = 0

# dualect: index k
for k in range(n - 1):
    k1 = f(t[k], y[:, k])
    k2 = f(t[k] + h / 2, y[:, k] + h / 2 * k1)
    k3 = f(t[k] + h / 2, y[:, k] + h / 2 * k2)
    k4 = f(t[k] + h, y[:, k] + h * k3)
    y[:, k + 1] = y[:, k] + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)

wd = w0 * np.sqrt(1 - zeta**2)  # dualect: assumed sqrt argument in the real domain
x_exact = np.exp(-zeta * w0 * t) * (np.cos(wd * t) + zeta * w0 / wd * np.sin(wd * t))
err = np.nanmax(np.abs(y[0, :] - x_exact))
energy = 0.5 * y[1, :] ** 2 + 0.5 * w0**2 * y[0, :] ** 2
decay = energy[-1] / energy[0]

Points worth noticing:

  • The first line records the verdict. dualect ran both files, and all 18 variables the .m script leaves behind matched within the default tolerance (the function handle f is only checked for being callable on both sides).
  • 0:h:t_end became np.linspace(0, t_end, 301), because np.arange with a fractional step can gain or lose the end point to rounding.
  • Loop indices shifted to 0-based. # dualect: index k tells the checker that k holds a position, so it compares it as one less than the .m value.
  • max became np.nanmax, because the .m function ignores NaN values and np.max does not.
  • Where dualect had to assume something it could not prove, such as sqrt of a non-negative number, it says so in a # dualect: assumed … comment.

How it works

Rules

Each rule is one folder, rules/<category>/<rule-id>/, with three parts:

rule.yaml
The construct in each language, why they differ and the known pitfalls, written to be read by a person learning the difference and by an LLM applying it.
translate.py
An optional deterministic translator for the construct, in one or both directions.
cases/
Pairs of .m and .py files that are run in both languages and compared in the test suite.

Keeping rules small and independent means that when a language changes, one folder changes and only its tests fail. A behavior that changed between language versions gets a new rule with its own version range rather than an edit to the old one.

Status

Every rule has a status for each direction of translation:

unsupported
The construct is documented so that it can be reported by name, but nothing translates it.
guided
No translator yet. The rule text and its cases are given to an LLM, and the LLM's output is verified like any other.
mechanical
The rule's translate.py translates it, and every case translates and verifies. The test suite fails if a mechanical rule stops qualifying, or if a guided rule qualifies and has not been promoted.

Pipeline

  1. Parse. .m files are parsed with tree-sitter; Python files with the standard ast module. Both are lowered into one small intermediate representation, so the same rules serve both directions.
  2. Scan. Each node is tagged with the rules it needs. Anything with no rule is recorded as unsupported, with its line and column.
  3. Translate. Each statement is translated by the translators of its rules. A statement that cannot be translated with confidence is left as a marked hole.
  4. Emit. The output is assembled; Python output is formatted with ruff.
  5. Verify. Both files are run and their results compared, unless verification is turned off.

Verification by execution

The .m side runs in GNU Octave through oct2py, in a cleared workspace; the Python side runs in a fresh namespace. Each variable the .m script leaves in its workspace must exist in Python with an equal value, compared with numpy.allclose (default rtol=1e-9, atol=1e-12, adjustable per rule) and with NaN equal to NaN. A 1×N row equals a 1-D NumPy array, but other shape differences fail, and so does logical against numeric. Index variables marked with a dualect: index comment are compared as 1-based against 0-based.

Verification shows that the two programs agree on the values the script computes when it runs as written. It does not prove that they agree on every possible input.

What it will not do

dualect does not guess. Its rule is that a wrong translation that looks right is worse than no translation.

  • A statement that needs a construct with no rule, or a translator that declines because the shape or type of a value is not known, is not translated. In Python output it becomes a TODO(dualect) comment followed by raise NotImplementedError(...); in .m output, a % TODO(dualect) comment followed by error(...). The report lists each one with its line and the reason.
  • An output that was not verified says so on its first line, for example # Translated from beam_deflection.m by dualect. UNVERIFIED: 1 statement(s) not translated.
  • --strict writes nothing at all if any statement would be left untranslated.
  • Python behavior with no .m counterpart is declined rather than imitated: writing through a NumPy view, a function that modifies its array argument, a lambda that reads a variable changed later, and max or argmax over data that may hold NaN.
  • Output is plain NumPy. There is no helper module that emulates the other language; a construct with no clean NumPy form stays guided or unsupported.

dualect itself never calls an LLM. When an LLM fills a hole, its code is checked with dualect verify like everything else.

Install and quick start

dualect needs Python 3.11 or later, uv, and GNU Octave for anything that runs .m code (translate with verification, verify, and the execution tests). It is not published on PyPI yet; run it from a clone.

brew install octave            # macOS; on Debian or Ubuntu: sudo apt install octave
git clone https://github.com/kovirlabs/dualect.git
cd dualect
uv sync --all-groups
uv run dualect --version
Installing from source.

From .m to Python

uv run dualect scan corpus/ode_rk4.m                    # which rules does it need?
uv run dualect translate corpus/ode_rk4.m -o ode_rk4.py # translate, then run both and compare
The direction follows the file suffix of the input.

The last lines of the report for this file are:

23 statements: 23 mechanical, 0 guided, 0 unsupported
verify corpus/ode_rk4.m <-> ode_rk4.py
  ok   decay
  …
PASS
verified: yes
wrote ode_rk4.py
Abridged; the full report also lists every statement and every compared variable.

The .m side may use Octave syntax such as # comments, !=, +=, do … until and endfunction. With --dialect matlab, those are reported as Octave-only.

From Python to .m

Given this trapezoid.py:

import numpy as np

x = np.linspace(0, np.pi, 101)
y = np.sin(x)
area = 0.0
for k in range(x.size - 1):
    area = area + (x[k + 1] - x[k]) * (y[k] + y[k + 1]) / 2
err = abs(area - 2)
Input: trapezoid.py

uv run dualect translate trapezoid.py -o trapezoid.m writes:

% Translated from trapezoid.py by dualect; verified against the .py source.
x = linspace(0, pi, 101);
y = sin(x);
area = 0.0;
for k = 0:numel(x) - 2
  area = area + (x(k + 2) - x(k + 1)) * (y(k + 1) + y(k + 2)) / 2;
end
err = abs(area - 2);
Output: trapezoid.m. Variables keep their Python values, so k still counts from 0 and the indices add 1.

Reading the report

For each statement, translate prints its line and column, its kind, the status used, whether it was verified, and the rules it used. Below a statement that was not translated, it prints why (for example needs call:cond (no rule)). The summary line counts statements by status, and the verified: line gives the verdict.

Exit codes of dualect translate
CodeMeaning
0Every statement was translated and the output verified.
1Verification ran and found a difference, or --strict refused to write an incomplete translation.
4The output was written but is not verified: it has untranslated statements, or --no-verify was given.

dualect verify exits with 0 when every variable matches and 1 otherwise. dualect rules check exits with 1 when a rule's status is not earned.

Commands

The dualect command line
CommandWhat it does
translate FILE -o OUTTranslates a .m file to Python or a .py file to .m, then verifies. Options: --strict, --no-verify, --dialect (for example octave or matlab@R2023a).
scan FILELists the rules a file needs, where each is used, and the constructs that have no rule. --json gives machine-readable output.
context FILEPrints the rules the file needs, with their cases, as Markdown for an LLM prompt.
verify M_FILE PY_FILERuns both scripts and compares every variable. Options: --rtol, --atol, --index NAME, --ignore NAME.
rules listLists every rule with its status in each direction and its number of cases. Filters: --status, --direction.
rules check [RULE_ID …]Runs each rule's translator on its cases in each direction and fails when a status is not earned.
rules new CATEGORY/NAMECreates a rule folder with a rule.yaml and one case pair to fill in.
llms-txt -o llms.txtRegenerates the one-line-per-rule index.
mcpServes scan, get_rule, translate and verify as tools over the Model Context Protocol, on standard input and output.

Working with an LLM

The guided path is how statements without a deterministic translation get done. dualect supplies the knowledge and the check; the LLM you already use writes the code.

  1. dualect translate leaves the statements it could not translate as marked holes.
  2. dualect context FILE prints only the rules that file needs: each rule's rule.yaml and one or two verified case pairs, ready to paste into a prompt.
  3. The LLM fills the holes.
  4. dualect verify runs both files and compares every variable.

llms.txt lists every rule in one line, with its status per direction and its number of cases, so a model can find the rule it needs without reading the whole repository.

An LLM client that supports the Model Context Protocol (MCP), such as Claude Code or Claude Desktop, can call the same functions as tools. A client configuration looks like this:

{"mcpServers": {"dualect": {"command": "uv",
  "args": ["run", "--directory", "/path/to/dualect", "dualect", "mcp"]}}}
MCP client configuration; replace /path/to/dualect with your clone.

Current coverage

dualect is at version 0.1.0 and early. These numbers come from dualect rules list and the test suite.

Rules
61, with 88 case pairs between them.
.m to Python
58 mechanical, 3 guided.
Python to .m
54 mechanical, 1 guided; 6 rules cover constructs that exist only in .m code.

Rules not yet mechanical

Rules that are guided in at least one direction
Rule.m to PythonPython to .mWhat is missing
builtins/tic-tocguidedguidedTiming has no value to compare, and dropping it silently is a judgement, not a translation.
functions/nargoutguidednot applicableA body that skips work when fewer outputs are requested has no single Python form.
io/displayguidednot applicableA missing semicolon displays values in a format that depends on the value.

The other 58 rules are mechanical in every direction they apply to. Besides the core language (literals, strings, indexing with end, ranges and masks, growing arrays, operators, control flow, functions with several outputs and nargin defaults, command syntax), they cover printing (printf, fprintf, sprintf, disp, num2str), filter, reductions and statistics (sum, mean, max, min, prod, cumsum, cumprod, diff, any, all, std, var, median), sort, unique, flip, reshape, repmat, concatenation, meshgrid, rand, linear algebra (norm, dot, cross, kron, inv, det, trace, rank), interp1, polyfit, polyval, trapz and element-wise math. The full list is in llms.txt.

Example scripts

The repository's corpus/ holds six realistic scripts that combine many constructs, translated from .m to Python in the test suite.

Results for the example scripts, .m to Python
ScriptStatementsTranslatedNot translatedVerified
ode_rk4.m23230Yes
beam_deflection.m30300Yes
signal_filter.m33330Yes
least_squares_fit.m27261: cond has no ruleNo
data_statistics.m28271: round of an expression is declinedNo
grid_heat.m26206: trapz along a dimension, ind2sub, and shapes lost after slicingNo

In the other direction, the Python that dualect writes for the three complete scripts translates back to .m completely and verifies against that Python.

Not supported yet

Any construct without a rule is reported as unsupported. Today that includes cell arrays, structs, plotting, file input and output, and built-in functions beyond the list above. Classes (classdef), GUIs, Simulink, MEX files and toolboxes are out of scope for now.