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
.mcode; NumPy starts at 0 and slices exclude their end. *is a matrix product in.mcode and an element-wise product in NumPy, where the matrix product is@.linspacedefaults to 100 points in.mcode and 50 in NumPy..marrays 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.
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);
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
.mscript leaves behind matched within the default tolerance (the function handlefis only checked for being callable on both sides). 0:h:t_endbecamenp.linspace(0, t_end, 301), becausenp.arangewith a fractional step can gain or lose the end point to rounding.- Loop indices shifted to 0-based.
# dualect: index ktells the checker thatkholds a position, so it compares it as one less than the.mvalue. maxbecamenp.nanmax, because the.mfunction ignores NaN values andnp.maxdoes not.- Where dualect had to assume something it could not prove, such as
sqrtof 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,
whythey differ and the knownpitfalls, 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
.mand.pyfiles 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.pytranslates it, and every case translates and verifies. The test suite fails if amechanicalrule stops qualifying, or if aguidedrule qualifies and has not been promoted.
Pipeline
- Parse.
.mfiles are parsed with tree-sitter; Python files with the standardastmodule. Both are lowered into one small intermediate representation, so the same rules serve both directions. - Scan. Each node is tagged with the rules it needs. Anything with no rule is recorded as unsupported, with its line and column.
- 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.
- Emit. The output is assembled; Python output is formatted with ruff.
- 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 byraise NotImplementedError(...); in.moutput, a% TODO(dualect)comment followed byerror(...). 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. --strictwrites nothing at all if any statement would be left untranslated.- Python behavior with no
.mcounterpart 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, andmaxorargmaxover 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
guidedorunsupported.
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
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 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
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)
trapezoid.pyuv 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);
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.
| Code | Meaning |
|---|---|
| 0 | Every statement was translated and the output verified. |
| 1 | Verification ran and found a difference, or --strict refused to write an incomplete translation. |
| 4 | The 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
| Command | What it does |
|---|---|
translate FILE -o OUT | Translates 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 FILE | Lists the rules a file needs, where each is used, and the constructs that have no rule. --json gives machine-readable output. |
context FILE | Prints the rules the file needs, with their cases, as Markdown for an LLM prompt. |
verify M_FILE PY_FILE | Runs both scripts and compares every variable. Options: --rtol, --atol, --index NAME, --ignore NAME. |
rules list | Lists 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/NAME | Creates a rule folder with a rule.yaml and one case pair to fill in. |
llms-txt -o llms.txt | Regenerates the one-line-per-rule index. |
mcp | Serves 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.
dualect translateleaves the statements it could not translate as marked holes.dualect context FILEprints only the rules that file needs: each rule'srule.yamland one or two verified case pairs, ready to paste into a prompt.- The LLM fills the holes.
dualect verifyruns 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"]}}}
/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.
.mto Python- 58
mechanical, 3guided. - Python to
.m - 54
mechanical, 1guided; 6 rules cover constructs that exist only in.mcode.
Rules not yet mechanical
| Rule | .m to Python | Python to .m | What is missing |
|---|---|---|---|
builtins/tic-toc | guided | guided | Timing has no value to compare, and dropping it silently is a judgement, not a translation. |
functions/nargout | guided | not applicable | A body that skips work when fewer outputs are requested has no single Python form. |
io/display | guided | not applicable | A 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.
| Script | Statements | Translated | Not translated | Verified |
|---|---|---|---|---|
ode_rk4.m | 23 | 23 | 0 | Yes |
beam_deflection.m | 30 | 30 | 0 | Yes |
signal_filter.m | 33 | 33 | 0 | Yes |
least_squares_fit.m | 27 | 26 | 1: cond has no rule | No |
data_statistics.m | 28 | 27 | 1: round of an expression is declined | No |
grid_heat.m | 26 | 20 | 6: trapz along a dimension, ind2sub, and shapes lost after slicing | No |
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.
Links
- Repository: source, issues and the README.
- Design document: architecture, the rule format, decisions and milestones.
- Rules, by category: syntax, indexing, operators, control flow, functions, built-ins, types, input and output.
llms.txt: every rule in one line.