The Python SDK¶
manhattan-reasoning-gym (imported as mrg) has three surfaces, matching the
three ways to use the platform:
| Surface | What it does | Needs |
|---|---|---|
mrg.build |
local synth / place-and-route reports, no board | Docker |
mrg.Sandbox |
run an untrusted agent in a locked container that can promote to silicon | Docker |
mrg.cloud |
program + drive a real ECP5 over the cloud | API key |
mrg.cloud: real hardware¶
You describe an application with three things: a design (an Amaranth
.py file, or a plain Verilog .v file, whose top-level module is a
Wishbone B4 slave), an optional register map (the byte offsets your
design exposes), and API config.
import manhattan_reasoning_gym as mrg
class Regs(mrg.cloud.RegisterMap):
CTRL = 0x0000
DATA_IN = 0x0004
DATA_OUT = 0x0008
app = mrg.cloud.App(
"my_design",
design="design.py",
registers=Regs,
)
@app.local_entrypoint()
def main():
app.write(Regs.DATA_IN, 0x1234)
app.write(Regs.CTRL, 1) # kick off
print(hex(app.read(Regs.DATA_OUT)))
Run it with the CLI, which programs the FPGA then calls the entrypoint:
App¶
mrg.cloud.App(
name,
*,
design, # path to the Amaranth .py or plain Verilog .v design
top=None, # Verilog-only top-module disambiguator, see below
fpga_id=None, # for --no-program reconnect only, see below
registers=None, # a RegisterMap subclass (optional)
api_key=None, # explicit arg > $MRG_API_KEY > `mrg login`
api_url=DEFAULT_API_URL,
sys_clk_freq=None, # SoC compute clock override (Hz)
timing_target_mhz=None, # PnR/grading timing target (MHz)
)
Creating an App registers it so the CLI can discover it, you don't export
anything. If a file defines several, mrg run uses the last one.
Verilog designs. design= also accepts a plain Verilog .v file — the
extension picks the language server-side, nothing else about App changes.
Since a .v file has no Elaboratable to scan for, its top module is found
by matching the one module whose port list has this Wishbone contract by
name, width, and direction:
| Port | Width | Direction |
|---|---|---|
clk |
1 | input |
rst |
1 | input |
wb_cyc |
1 | input |
wb_stb |
1 | input |
wb_we |
1 | input |
wb_adr |
9 | input |
wb_dat_w |
32 | input |
wb_sel |
4 | input |
wb_dat_r |
32 | output |
wb_ack |
1 | output |
If a file has more than one module matching this contract, pass top="..."
(ignored for an Amaranth design) to disambiguate; with only one match it's
auto-detected. See the Verilog Hello example.
fpga_id is normally left unset. A fresh build never picks a board
itself — the server claims a build slot (a network identity baked into the
bitstream, independent of any physical board) and dispatches the build
immediately; whichever board frees up first claims the finished bitstream and
flashes it. app.fpga_id is filled in from that completed job once
_program() finishes, not chosen up front. Pass fpga_id
explicitly only to reconnect to a board you already have a live session on
without rebuilding (mrg run --no-program --fpga-id N / App(...,
fpga_id=N) with programming skipped) — on any run that does build, whatever
you pass here is overwritten with the real assigned board.
Sys clock vs. timing target
A build carries two independent frequencies. Sys clock is what the SoC
actually runs at (produced by the ECP5 PLL from a fixed 12 MHz input,
default 50 MHz), because the PLL divides that fixed input, only
certain output frequencies are realizable. Timing target is the
frequency place-and-route is constrained to hit and the build is
graded against; it carries no PLL restriction, so it can be any value,
and defaults to the sys clock. Keeping them separate lets you ask "can
this design close at 90 MHz?" without re-clocking the SoC, and grade
against thresholds the PLL can't synthesize exactly. Set both via
App(sys_clk_freq=..., timing_target_mhz=...) or the CLI's
--sys-clk/--timing-target-mhz flags.
app.read(addr, count=1)¶
Read count 32-bit words starting at byte address addr. Returns a single
int when count == 1, otherwise a list[int]. Programs the FPGA first if
it hasn't been programmed yet.
app.write(addr, value, fixed_address=False)¶
Write one or more 32-bit words to byte address addr. value may be a single
int or a list[int] for a burst write, which increments the address by 4
per word (loading a register array, like a RAM). Pass fixed_address=True to
instead repeat addr for every word in the burst -- for a FIFO or
push-register port where your design keeps its own internal write index
(a common streaming-load pattern: writing a sequence of words one at a time to
a single register, with the RTL auto-advancing into the next clause/slot). A
plain burst would scatter those words across whatever registers happen to sit
at addr+4, addr+8, ... instead of pushing them all through the one port.
app.stream()¶
with app:
with app.stream() as s:
for word in words:
s.write(LITERAL_IN, word, fixed_address=True)
s.write(REG_CTRL, 1)
while not (s.read(REG_CTRL) & 1):
pass
Opens a persistent, low-latency session for many small read/write ops. It
is a context manager exposing the same write(addr, value, fixed_address=False)
and read(addr, count=1) methods as App, but skips the per-call job queue:
every op on app.write()/app.read() directly dispatches its own job against
the cloud API and then polls for completion every 0.5s, so each individual
call costs roughly that much wall-clock time no matter how small the payload
is. That's fine for a handful of calls, but it dominates for a tight loop --
loading a CNF instance one literal per write, or an RL reward loop that needs
to load and grade many episodes per training step. A Stream instead holds
one WebSocket open for the whole with block, relayed straight through to the
FPGA's Wishbone bus, and pays that connection cost once instead of once per
operation.
A stream and app.write()/app.read() (or a second stream) can't be used on
the same FPGA at the same time -- the bridge firmware only safely serves one
open connection per board, so opening a stream holds an exclusive lock on the
FPGA's link for the life of the with block.
Per-write payload limit: a burst write(addr, value, ...) is capped at
roughly 2KB (~500 32-bit words). Going over it closes the connection,
raised client-side as websockets.exceptions.ConnectionClosedError with the
reason "FPGA is busy with a run job" -- ignore that text, it's a payload
size limit, not a concurrency/session issue. If you need to send more than
that, split value into chunks under ~500 words and call write() once per
chunk; fixed_address=True composes fine across chunked calls.
app.release()¶
Release the active session, returning the board to idle. Returns the reset
job_id. App is also a context manager that calls this on exit.
RegisterMap¶
A marker base class for your design's address map, subclass it and set
integer class attributes for the byte offsets. Byte offset = 4 × word offset.
Passing registers= to App is optional; it's for your own organization,
read/write take raw addresses either way.
@app.local_entrypoint()¶
Marks the function mrg run calls after programming. Not called on plain
import/python, so the same file doubles as an importable module and a
runnable app.
secret(env_var)¶
Reads a required environment variable, raising immediately if it's unset,
useful for API keys you want to fail loudly on rather than silently pass
through as None.
Module-level session functions¶
mrg.cloud.get_session(fpga_id, api_key, api_url) # current session info
mrg.cloud.release_session(fpga_id, api_key, api_url) # release, returns job_id
mrg.Sandbox: locked agent execution¶
For running an untrusted agent: the agent gets a locked-down container
with no API key and no network. Having vetted a candidate locally with
mrg.build, it calls mrg.sandbox.promote(...), a file handoff on the
shared workspace, and your trusted process outside the container decides
whether to run it on real silicon.
import manhattan_reasoning_gym as mrg
sb = mrg.Sandbox(files=["design.py", "agent.py"]) # mock silicon unless a key is set
result = sb.run("agent.py")
for promotion in result.promotions:
print(promotion)
mrg.Sandbox(
files=(),
*,
isolation="locked", # or "dev", or a custom SandboxProfile
silicon="auto", # "auto" | "cloud" | "mock" | a callable
api_key=None,
api_url=None,
sys_clk_freq=None,
guard=None, # optional (design_bytes, report) -> reject reason | None
image=None,
poll_interval=0.2,
)
sb.run(entrypoint, *, timeout=1800) launches entrypoint in the container
and brokers its promotes to silicon, returning a SandboxResult(returncode,
stdout, stderr, promotions).
No gating by default: the framework doesn't decide whether a promote is
"good enough"; the agent gates itself, and you only get a promote check if you
pass guard=. The trust boundary is fixed: the container has no key and
no network; promote is a file handoff on the shared workspace; only the
trusted process outside the container holds the key and touches silicon.
Inside the container: mrg.sandbox.promote¶
The only function available to a sandboxed agent, it never holds a key or calls the orchestrator directly:
report = mrg.build.pnr("design.py")
if report.fits and report.timing_met:
mrg.sandbox.promote("design.py", report, agent="demo-agent")
mrg.build: local synth / PnR¶
Fast, no-cloud, no-board build feedback. The same call works everywhere: run
inside the sandbox image (toolchain present) executes in-process; run on a
plain host transparently runs the pinned Docker image and parses its JSON
report. Either way you get a BuildReport back.
mrg.build.synth(design, *, top=None, work=None) -> BuildReport
mrg.build.pnr(
design, *,
top=None, # Verilog-only top-module disambiguator
target_mhz=None, # legacy alias, sets both knobs below
sys_clk_mhz=None,
timing_target_mhz=None,
seed=1,
work=None,
) -> BuildReport
synth: resource utilization only, fast, no timing analysis.pnr: full-SoC place-and-route (Fmax,timing_met, SoC-wide utilization).designaccepts an Amaranth.pyor plain Verilog.vfile;topis the same Verilog-only disambiguator asApp(top=...)(ignored for Amaranth), only needed when a.vfile exposes more than one module matching the Wishbone contract.
BuildReport fields: mode, ok, scope, fits, fmax_mhz, sys_clk_mhz,
target_mhz, timing_met, clock, util, synth_cells, warnings,
design_hash, toolchain, log_tail, plus .to_json()/.to_dict().
See also¶
- CLI reference, driving all three surfaces from the terminal.
- Examples, complete, runnable apps.
- API Reference, the full generated docstring reference.