Skip to content
SawyDust1228Public

About

[ICCD2026] CPPL: A Circuit Prompt Programming Language

Topics

Resources

Stars

21 stars

Watchers

0 watching

Forks

Latest commit

 

History

19 Commits

Folders and files

Repository files navigation

CircuitPPL

A Python DSL for hardware description with LLM-powered compilation. Define hardware modules as Python functions, let an LLM generate the logic, and compile to MLIR or Verilog.

Architecture

Python DSL (@module)  →  LLM Compilation  →  JSON-IR  →  MLIR / Verilog
  • Frontend: @module decorator extracts ports and logic descriptions from Python functions
  • Compiler: LLM generates JSON-IR body from natural-language descriptions (with validation feedback loop)
  • IR: SSA-based intermediate representation with parsing, validation, and bit-width inference
  • Interpreter: pure-Python, two-state functional and cycle simulation of JSON-IR
  • Codegen: MLIR/CIRCT emission via pycde, with Verilog export

Quick Start

Define Modules

from cppl import module, In, Out, Clock, Design

@module
def Adder8(a: In[8], b: In[8]) -> Out[8]:
    """out equals a plus b (8-bit addition)."""
    pass

@module
def ALU(op_code: In[2], op_a: In[8], op_b: In[8]) -> {"res": Out[8], "zero": Out[1]}:
    adder8_out = Adder8(op_a, op_b)
    return f"""
    Based on op_code:
    - 00: res = {adder8_out} (from Adder8 instance)
    - 01: res = op_a - op_b
    - 10: res = op_a & op_b
    - 11: res = op_a | op_b
    zero is 1 when res equals 0, otherwise 0.
    """

design = Design()
design.add(ALU)  # automatically discovers and adds Adder8
print(design.to_verilog())

Key points:

  • In[N] / Out[N] specify N-bit input/output ports
  • Clock specifies a dedicated clock input port, for example clk: Clock
  • Single output uses -> Out[N] (named "out"), multiple outputs use -> {"name": Out[N], ...}
  • Docstrings describe the logic in natural language for the LLM
  • Modules can instantiate other modules by calling them; use f-string returns to reference instance outputs
  • Design.add() recursively discovers and adds all sub-modules — only the top-level module needs to be added

Executable I/O Patterns

Use @module(patterns=[...]) to provide examples that are executed against each generated IR candidate. Patterns are included in the generation prompt and are also independently checked by the IR interpreter. A mismatch is returned to the repair agent with the failing inputs, expected outputs, and actual outputs.

from cppl import Case, Clock, In, Out, Sequence, Step, module

@module(patterns=[
    Case(inputs={"a": 1, "b": 2}, outputs={"out": 3}),
])
def Adder(a: In[8], b: In[8]) -> Out[8]:
    """out equals a plus b."""
    pass

@module(patterns=[
    Sequence(steps=[
        Step(inputs={"clk": 0, "d": 7}),
        Step(inputs={"clk": 1}, outputs={"out": 7}),
    ]),
])
def Register(clk: Clock, d: In[8]) -> Out[8]:
    """Capture d on the rising edge of clk."""
    pass

Each Case or Sequence starts from reset interpreter state. Steps within one sequence share state, omitted inputs retain their previous values, and only the listed outputs are checked. Passing patterns demonstrate agreement with those examples; they are executable tests rather than a formal proof for every input.

Agent Runtime

CPPL's complete agent system is implemented with LangChain and LangGraph. Every user-written @module is one atomic compilation boundary; the agent never splits it into hidden submodules. LangChain owns provider chat models, prompt pipelines, JSON parsing, and transport retries. A per-module LangGraph handles cache lookup, generation, validation, requirement compression, and repair loops; a deterministic hierarchy scheduler compiles the module DAG bottom-up, runs all dependency-ready siblings concurrently, publishes validated child artifacts, and then releases their parents for hierarchical validation. Validated results are cached by content under .cppl/cache, so compiling an unchanged design again requires no LLM calls.

Existing code continues to work. Optional runtime controls and reports are available through AgentConfig:

from cppl import AgentConfig, Design

design = Design(agent_config=AgentConfig(
    max_parallelism=4,
    context_window_tokens=32768,
    cache_enabled=True,
    tool_mode="auto",       # auto | off | required
    max_tool_rounds=12,
))
design.add(ALU)

report = design.compile_with_report(max_retries=3)
print(report.llm_calls, report.cache_hits)
for name, module_report in report.module_reports.items():
    print(
        name,
        module_report.status,
        module_report.attempts,             # generation/repair sessions
        module_report.model_turns,           # actual provider requests
        module_report.tool_calls,            # isolated IR tool calls
        module_report.simulation_attempts,  # candidates checked by patterns
        module_report.static_repairs,       # JSON/schema/SSA/width repairs
    )

max_retries limits only executable simulation/pattern attempts. JSON syntax, schema, SSA, port, instance, and width errors are sent back to the Repair Agent without consuming this counter; those repairs continue until valid or until the module time/token budget is exhausted. CompileOptions.max_simulation_attempts provides the equivalent per-run override.

Python configuration overrides CPPL_AGENT_* environment variables. Reports contain timing, token estimates, cache and diagnostic metadata, but never store API keys or complete prompts/responses.

Tool-capable models can incrementally construct and repair JSON-IR through an isolated module-local session. The always-available tools cover replacement, JSON Patch edits, JSON Pointer queries, validation, pattern simulation, and submission. If jq, rg, or diff are installed they are used as bounded Unix enhancements; they are optional and are never invoked through a shell. Tools operate only on temporary candidate snapshots and cannot modify project files. In the default auto mode, models or custom backends without native tool calling transparently use the existing structured/direct JSON generation path.

The corresponding environment controls are CPPL_AGENT_TOOL_MODE, CPPL_AGENT_MAX_TOOL_ROUNDS, CPPL_AGENT_TOOL_TIMEOUT, and CPPL_AGENT_MAX_TOOL_OUTPUT_CHARS.

Compilation logs are enabled by default and written to stderr, so JSON, MLIR, and Verilog output on stdout remains clean:

[CPPL    0.02s] START    — compiling 2 module(s)
[CPPL    0.05s] GENERATE Adder8 — generation call 1; next simulation attempt 1/3
[CPPL    1.24s] SUCCESS  Adder8 — validated in 1.19s
[CPPL    2.12s] DONE     — 2 module(s), 2 LLM call(s), 0 cache hit(s) in 2.10s

Use AgentConfig(log_enabled=False) or CPPL_AGENT_LOG_ENABLED=false for silent compilation. Custom applications can receive the same structured event stream through an observer:

from cppl import CompileEvent, Design

class MyObserver:
    def on_event(self, event: CompileEvent) -> None:
        save_event(event)

design = Design(observer=MyObserver())

Run the Demo

Install (editable / development mode):

pip install -e .

Run the example as a normal Python script:

python examples/python/cppl_demo.py

JSON-IR Format

The intermediate representation is a JSON array of modules. Each module has name, ports, and a body of SSA operations:

[{
  "name": "Adder8",
  "ports": {
    "a": {"dir": "input", "width": 8},
    "b": {"dir": "input", "width": 8},
    "sum": {"dir": "output", "width": 8}
  },
  "body": [
    {"id": "result", "op": "add", "args": ["a", "b"]},
    {"op": "output", "args": {"sum": "result"}}
  ]
}]

Supported operations: constant, add, sub, mul, div, div_s, mod_u, mod_s, and, or, xor, not, neg, reverse, shl, shr_u, shr_s, eq, ne, lt_s, lt_u, ge_s, ge_u, gt_s, gt_u, le_s, le_u, or_reduce, and_reduce, xor_reduce, concat, extract, mux, sext, zext, reg, mem, instance, output.

IR Simulation

The pure-Python interpreter supports the complete JSON-IR, including registers, memories, multiple clock domains, and hierarchical instances. Inputs retain their previous values when omitted; registers and uninitialized memories start at zero. A register or memory updates only when its clock input changes from 0 to 1.

from cppl import Interpreter

sim = Interpreter.from_json(
    open("examples/json/register.json").read(), top="BasicReg"
)

print(sim.evaluate({"clk": 0, "d": 42}))  # {"q": 0}
print(sim.evaluate({"clk": 1}))           # {"q": 42}
print(sim.peek("q_reg"))                   # inspect an internal SSA value

For DSL designs, use design.interpreter(top="ModuleName") after adding the modules. peek_memory("instance.path.memory_name") returns an immutable memory snapshot, and reset_state() restores initial register and memory contents.

Memory (mem)

The mem op creates register-array memories with configurable read/write ports:

{
  "id": ["rdata"],
  "op": "mem",
  "width": 32,
  "depth": 1024,
  "clock": "clk",
  "reset": "rst",
  "reads": [{"addr": "raddr", "enable": "ren"}],
  "writes": [
    {"addr": "waddr", "data": "wdata", "enable": "wen", "mask": "wmask"}
  ]
}
  • id has one entry per read port; each output is width bits wide
  • reads: combinational read ports (latency 0); writes: synchronous write ports (latency 1)
  • mask is optional and width bits wide; 1 updates the corresponding stored bit from data, while 0 preserves the old stored bit
  • Generates CIRCT seq.hlmem ops, lowered to sv.reg arrays in Verilog

Dependencies

  • LangChain — chat models and prompt pipelines
  • LangGraph — agent workflow orchestration
  • pycde — Python CIRCT bindings for MLIR/Verilog emission
  • An LLM API key configured for your chosen provider

Environment Configuration

Create a root .env file for your provider credentials and model selection. CircuitPPL reads LangChain model configuration from .env.

Recommended generic variables:

LLM_PROVIDER=openai
LLM_MODEL=openai/gpt-4.1
LLM_API_KEY=your_api_key
LLM_BASE_URL=https://api.openai.com/v1

You can also use provider-specific variables when you prefer:

LLM_PROVIDER=deepseek
DEEPSEEK_MODEL=deepseek/deepseek-chat
DEEPSEEK_API_KEY=your_api_key
DEEPSEEK_BASE_URL=https://api.deepseek.com

Notes:

  • LLM_PROVIDER, LLM_MODEL, LLM_API_KEY, and LLM_BASE_URL are the primary, provider-agnostic settings.
  • If LLM_PROVIDER is set, CircuitPPL also checks ${PROVIDER}_MODEL, ${PROVIDER}_API_KEY, and ${PROVIDER}_BASE_URL.
  • Existing fallbacks like ANTHROPIC_API_KEY, OPENAI_API_KEY, ANTHROPIC_BASE_URL, and OPENAI_BASE_URL still work.
  • LLM_MODEL is required for DSL compilation.
  • anthropic/* models use langchain-anthropic; OpenAI and OpenAI-compatible endpoints use langchain-openai. Non-OpenAI compatible providers must set a base URL.
  • Optional generation overrides are available via LLM_TEMPERATURE, LLM_TOP_P, and LLM_REASONING_EFFORT. By default CircuitPPL does not force temperature, which avoids compatibility issues with models such as gpt-5.

Citation

If you use CPPL in your research, please cite:

@misc{yin2026cpplcircuitpromptprogramming,
      title={CPPL: A Circuit Prompt Programming Language}, 
      author={Shuo Yin and Yihe Wang and Lancheng Zou and Xufeng Yao and Tinghuan Chen and Chen Bai and Zhengrong Wang and Tsung-Yi Ho and Bei Yu},
      year={2026},
      eprint={2605.17892},
      archivePrefix={arXiv},
      primaryClass={cs.AR},
      url={https://arxiv.org/abs/2605.17892}, 
}

Project Structure

cppl/
├── frontend/          # DSL types, @module decorator, LLM compiler, prompts
├── ir/                # JSON-IR models, parser, validator, width inference
├── codegen/           # MLIR/Verilog generation via CIRCT
examples/
├── json/              # JSON-IR examples
└── python/            # Python DSL examples
pyproject.toml         # Package configuration

About

[ICCD2026] CPPL: A Circuit Prompt Programming Language

Topics

Resources

Stars

21 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages