From dcac005856b1cb1a8aa90400b33f02b32c42a030 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 1 Mar 2026 10:42:34 -0500 Subject: [PATCH] feat(asm): add opcode parameter support for macros Grammar: opcode rule now accepts param_ref alternative, positional_arg accepts OPCODE token. Lower pass defers ParamRef opcodes and wraps bare OPCODE tokens in macro call arguments as strings. Expand pass resolves opcode mnemonic strings to ALUOp/MemOp via MNEMONIC_TO_OP during macro body cloning. Enables: #reduce_2 op |> { &r <| ${op} } / #reduce_2 add --- asm/expand.py | 25 +++- asm/ir.py | 2 +- asm/lower.py | 23 ++-- dfasm.lark | 4 +- docs/test-requirements.md | 197 +++++++++++++++++++++++++++++ tests/test_opcode_params.py | 242 ++++++++++++++++++++++++++++++++++++ 6 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 docs/test-requirements.md create mode 100644 tests/test_opcode_params.py diff --git a/asm/expand.py b/asm/expand.py index 166584c..4161dbc 100644 --- a/asm/expand.py +++ b/asm/expand.py @@ -21,6 +21,7 @@ from asm.ir import ( IRGraph, IRNode, IREdge, IRRegion, RegionKind, ParamRef, ConstExpr, MacroDef, IRMacroCall, CallSiteResult, CallSite, IRRepetitionBlock, SourceLoc ) +from asm.opcodes import MNEMONIC_TO_OP from cm_inst import Port, RoutingOp MAX_EXPANSION_DEPTH = 32 @@ -307,6 +308,28 @@ def _clone_and_substitute_node( message=str(e), )) + # Resolve opcode if it's a ParamRef + new_opcode = node.opcode + if isinstance(new_opcode, ParamRef): + resolved = _substitute_param(new_opcode, subst_map) + if isinstance(resolved, str): + if resolved in MNEMONIC_TO_OP: + new_opcode = MNEMONIC_TO_OP[resolved] + else: + errors.append(AssemblyError( + loc=node.loc, + category=ErrorCategory.MACRO, + message=f"'{resolved}' is not a valid opcode mnemonic", + )) + new_opcode = node.opcode + else: + errors.append(AssemblyError( + loc=node.loc, + category=ErrorCategory.MACRO, + message=f"opcode parameter must resolve to an opcode mnemonic, got {type(resolved).__name__}", + )) + new_opcode = node.opcode + # Substitute the node name (may be a ParamRef with token pasting) substituted_name = _substitute_param(node.name, subst_map) @@ -317,7 +340,7 @@ def _clone_and_substitute_node( # Qualify the node name new_name = _qualify_expanded_name(substituted_name, macro_scope, parent_scope, func_scope) - return replace(node, name=new_name, const=new_const), errors + return replace(node, name=new_name, const=new_const, opcode=new_opcode), errors def _clone_and_substitute_edge( diff --git a/asm/ir.py b/asm/ir.py index f6686a8..010fac4 100644 --- a/asm/ir.py +++ b/asm/ir.py @@ -78,7 +78,7 @@ class IRNode: sm_id: Optional SM ID for MemOp instructions (populated during lowering) """ name: Union[str, ParamRef] - opcode: Union[ALUOp, MemOp] + opcode: Union[ALUOp, MemOp, ParamRef] dest_l: Optional[Union[NameRef, ResolvedDest]] = None dest_r: Optional[Union[NameRef, ResolvedDest]] = None const: Optional[Union[int, ParamRef, ConstExpr]] = None diff --git a/asm/lower.py b/asm/lower.py index 1e13ec8..0be4cdc 100644 --- a/asm/lower.py +++ b/asm/lower.py @@ -977,9 +977,18 @@ class LowerTransformer(Transformer): positional_args = [] named_args: dict[str, object] = {} + found_name = False for item in args: if isinstance(item, LarkToken): - # Skip the macro name token + if not found_name: + # First LarkToken is the macro name + found_name = True + continue + if item.type == "OPCODE": + # Bare opcode token as macro argument — wrap as string + positional_args.append(str(item)) + continue + # Skip other tokens (FLOW_OUT, commas, etc.) continue elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], str): # Named argument from named_arg rule (name, value) @@ -987,8 +996,8 @@ class LowerTransformer(Transformer): elif isinstance(item, dict) and "name" in item: # Positional argument (qualified_ref or value) positional_args.append(item) - elif item is not None and not isinstance(item, LarkToken): - # Other argument types + elif item is not None: + # Other argument types (int literals, etc.) positional_args.append(item) macro_call = IRMacroCall( @@ -1242,17 +1251,17 @@ class LowerTransformer(Transformer): return (str(param_name), value) @v_args(inline=True) - def opcode(self, token: LarkToken) -> Optional[Union[ALUOp, MemOp]]: - """Map opcode token to ALUOp or MemOp enum, or None if invalid.""" + def opcode(self, token) -> Optional[Union[ALUOp, MemOp, ParamRef]]: + """Map opcode token to ALUOp/MemOp enum, ParamRef, or None if invalid.""" + if isinstance(token, ParamRef): + return token mnemonic = str(token) if mnemonic not in MNEMONIC_TO_OP: - # Add error but don't crash self._errors.append(AssemblyError( loc=SourceLoc(line=token.line, column=token.column), category=ErrorCategory.PARSE, message=f"Unknown opcode '{mnemonic}'", )) - # Return None for invalid mnemonic return None return MNEMONIC_TO_OP[mnemonic] diff --git a/dfasm.lark b/dfasm.lark index 40660f9..4f9c960 100644 --- a/dfasm.lark +++ b/dfasm.lark @@ -103,7 +103,7 @@ PORT_SPEC: IDENT | HEX_LIT | DEC_LIT ?argument: named_arg | positional_arg named_arg: IDENT "=" positional_arg -?positional_arg: value | qualified_ref +?positional_arg: value | qualified_ref | OPCODE // === Values (literals) === @@ -137,7 +137,7 @@ call_output: IDENT "=" qualified_ref -> named_output // at the lexer level. Semantic validation (monadic/dyadic arity, valid // argument combinations) is deferred to the assembler. -opcode: OPCODE +opcode: OPCODE | param_ref OPCODE.2: "add" | "sub" | "inc" | "dec" | "shiftl" | "shiftr" | "ashiftr" diff --git a/docs/test-requirements.md b/docs/test-requirements.md new file mode 100644 index 0000000..be71049 --- /dev/null +++ b/docs/test-requirements.md @@ -0,0 +1,197 @@ +# Macro Enhancements — Test Requirements + +Maps each enhancement from `docs/macro-enhancements.md` to specific automated test cases. + +Slug: `macro-enh` + +--- + +## Enhancement 1: Opcode Parameters + +### macro-enh.E1.1: Grammar accepts `param_ref` in opcode position + +- **E1.1a Success:** `&r <| ${op}` parses without error inside a macro body +- **E1.1b Success:** `${op} &src |> &dst` (strong_edge with param opcode) parses +- **E1.1c Success:** `&dst ${op} <| &src` (weak_edge with param opcode) parses +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_opcode_params.py` + +### macro-enh.E1.2: Lower pass stores ParamRef in IRNode.opcode + +- **E1.2a Success:** After lowering a macro body with `${op}`, the IRNode has `opcode` as `ParamRef(param="op")` +- **E1.2b Success:** Anonymous nodes from strong/weak edges with `${op}` also have ParamRef opcode +- **Test type:** Unit (lower) +- **File:** `tests/test_opcode_params.py` + +### macro-enh.E1.3: OPCODE accepted as positional macro argument + +- **E1.3a Success:** `#reduce_2 add` parses — bare `add` in macro call argument position is accepted +- **E1.3b Success:** Lower pass wraps the OPCODE token as a string `"add"` in `IRMacroCall.positional_args` +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_opcode_params.py` + +### macro-enh.E1.4: Expand pass resolves opcode ParamRef + +- **E1.4a Success:** Macro `#wrap op |> { &n <| ${op} }` invoked as `#wrap add` produces node with `opcode=ArithOp.ADD` +- **E1.4b Success:** Macro invoked with `sub`, `gate`, `read` (different op types) all resolve correctly +- **E1.4c Failure:** Macro invoked with `#wrap banana` produces MACRO error — invalid opcode mnemonic +- **E1.4d Failure:** Macro invoked with `#wrap 42` (numeric, not opcode) produces MACRO error +- **Test type:** Unit (expand) +- **File:** `tests/test_opcode_params.py` + +### macro-enh.E1.5: Full pipeline with opcode params + +- **E1.5a Success:** `#reduce_2 op |> { &r <| ${op} }` + `#reduce_2 add` assembles through full pipeline (parse → lower → expand → resolve → place → allocate → codegen) +- **E1.5b Success:** Output PEConfig has correct ALUInst with ArithOp.ADD +- **Test type:** Integration (full pipeline via `assemble()`) +- **File:** `tests/test_opcode_params.py` + +--- + +## Enhancement 2: Parameterized Placement and Port Qualifiers + +### macro-enh.E2.1: Grammar accepts `param_ref` in placement position + +- **E2.1a Success:** `&n <| add |${pe}` parses inside macro body +- **E2.1b Success:** Lower pass returns `ParamRef` (wrapped as `PlacementRef`) from placement handler +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.2: Grammar accepts `param_ref` in port position + +- **E2.2a Success:** `&src |> &dst:${port}` parses inside macro body +- **E2.2b Success:** Lower pass returns `ParamRef` (wrapped as `PortRef`) from port handler +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.3: Context slot bracket syntax parses + +- **E2.3a Success:** `&node[2]` parses (literal context slot) +- **E2.3b Success:** `&node|pe0[2]:L` parses (full qualifier chain) +- **E2.3c Success:** `&node[${ctx}]` parses (parameterized context slot) +- **E2.3d Success:** `&node[0..4]` parses (range reservation) +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.4: Expand pass resolves placement ParamRef + +- **E2.4a Success:** Macro with `|${pe}` invoked with `pe0` places node on PE 0 +- **E2.4b Success:** Macro with `|${pe}` invoked with `pe1` places node on PE 1 +- **E2.4c Failure:** Macro invoked with `|${pe}` where arg is `"banana"` produces MACRO error +- **Test type:** Unit (expand) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.5: Expand pass resolves port ParamRef + +- **E2.5a Success:** Macro with `:${port}` invoked with `L` resolves to `Port.L` +- **E2.5b Success:** Macro with `:${port}` invoked with `R` resolves to `Port.R` +- **E2.5c Failure:** Macro invoked with invalid port value produces MACRO error +- **Test type:** Unit (expand) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.6: Expand pass resolves context slot ParamRef + +- **E2.6a Success:** Macro with `[${ctx}]` invoked with `2` resolves to ctx slot 2 +- **E2.6b Failure:** Non-numeric ctx slot value produces MACRO error +- **Test type:** Unit (expand) +- **File:** `tests/test_qualified_ref_params.py` + +### macro-enh.E2.7: Full pipeline with placement/port params + +- **E2.7a Success:** Macro parameterizing PE placement assembles through full pipeline; node placed on correct PE +- **E2.7b Success:** Macro parameterizing port assembles through full pipeline; edge targets correct port +- **Test type:** Integration (full pipeline) +- **File:** `tests/test_qualified_ref_params.py` + +--- + +## Enhancement 3: @ret Wiring for Macros + +### macro-enh.E3.1: Grammar accepts output list on macro_call_stmt + +- **E3.1a Success:** `#macro args |> &dest` parses +- **E3.1b Success:** `#macro args |> name=&dest` parses (named output) +- **E3.1c Success:** `#macro args |> &a, &b` parses (multiple outputs) +- **E3.1d Success:** `#macro args |> name1=&a, name2=&b` parses (multiple named outputs) +- **Test type:** Unit (parse + lower) +- **File:** `tests/test_macro_ret.py` + +### macro-enh.E3.2: Lower pass stores output_dests on IRMacroCall + +- **E3.2a Success:** `IRMacroCall.output_dests` contains positional output refs +- **E3.2b Success:** `IRMacroCall.output_dests` contains named output refs (name, ref) tuples +- **Test type:** Unit (lower) +- **File:** `tests/test_macro_ret.py` + +### macro-enh.E3.3: Expand pass rewrites @ret edges + +- **E3.3a Success:** Macro body edge `&src |> @ret` becomes `&src |> &actual_dest` after expansion +- **E3.3b Success:** Named `@ret_body` maps to `body=&dest` in call site output +- **E3.3c Success:** Multiple @ret variants (e.g., `@ret_body` + `@ret_exit`) each map to their named outputs +- **E3.3d Failure:** `@ret_body` in macro body but call site has no `body=` output → MACRO error +- **E3.3e Failure:** Macro body has `@ret` but call site provides zero outputs → MACRO error +- **E3.3f Success:** Positional @ret maps to first positional output +- **Test type:** Unit (expand) +- **File:** `tests/test_macro_ret.py` + +### macro-enh.E3.4: @ret port preservation + +- **E3.4a Success:** `&src |> @ret:R` rewrites to `&src |> &dest:R` — port on @ret is preserved +- **E3.4b Success:** `&src |> @ret_exit:R` also preserves port +- **Test type:** Unit (expand) +- **File:** `tests/test_macro_ret.py` + +### macro-enh.E3.5: Nested macro @ret scoping + +- **E3.5a Success:** Macro A calls macro B which has @ret; B's @ret resolves at B's call site (inside A's body), A's @ret resolves at A's call site +- **Test type:** Unit (expand) +- **File:** `tests/test_macro_ret.py` + +### macro-enh.E3.6: Full pipeline with @ret macros + +- **E3.6a Success:** Macro with @ret + call site output list assembles through full pipeline +- **E3.6b Success:** Generated edges connect expanded macro internals to call-site-specified destinations +- **Test type:** Integration (full pipeline) +- **File:** `tests/test_macro_ret.py` + +--- + +## Enhancement 4: Built-in Macro Rewrite + +### macro-enh.E4.1: New builtins use opcode params + +- **E4.1a Success:** `#reduce_2 add` expands correctly (single node with ArithOp.ADD) +- **E4.1b Success:** `#reduce_3 sub` expands correctly (two nodes with ArithOp.SUB, wired) +- **E4.1c Success:** `#reduce_4 add` expands correctly (three nodes, tree structure) +- **Test type:** Unit (expand) +- **File:** `tests/test_builtins_v2.py` + +### macro-enh.E4.2: New builtins use @ret wiring + +- **E4.2a Success:** `#loop_counted |> body=&proc, exit=&done` wires @ret_body → &proc, @ret_exit → &done +- **E4.2b Success:** `#loop_while |> body=&proc, exit=&done` wires similarly +- **Test type:** Unit (expand) +- **File:** `tests/test_builtins_v2.py` + +### macro-enh.E4.3: Backwards compatibility + +- **E4.3a:** Old macro names that are removed are documented in CHANGELOG or similar +- **Test type:** Manual verification (pre-1.0, acceptable breakage) + +### macro-enh.E4.4: Full pipeline with new builtins + +- **E4.4a Success:** Program using `#loop_counted |> body=&body, exit=&done` + `#reduce_2 add` assembles through full pipeline +- **Test type:** Integration (full pipeline) +- **File:** `tests/test_builtins_v2.py` + +--- + +## Human Verification + +### macro-enh.HV1: dfgraph renders programs using new macros +- Verify that dfgraph correctly visualises programs using opcode params, @ret wiring +- **Justification:** Graph rendering depends on pipeline output; visual verification needed + +### macro-enh.HV2: Error messages are useful +- Verify that error messages for invalid opcode params, mismatched @ret, etc. include actionable context (macro name, line, suggestions) +- **Justification:** Error message quality is subjective; automated tests check presence but not clarity diff --git a/tests/test_opcode_params.py b/tests/test_opcode_params.py new file mode 100644 index 0000000..2c70eb9 --- /dev/null +++ b/tests/test_opcode_params.py @@ -0,0 +1,242 @@ +"""Tests for Enhancement 1: Opcode Parameters (macro-enh.E1.*). + +Tests verify: +- macro-enh.E1.1: Grammar accepts param_ref in opcode position +- macro-enh.E1.2: Lower pass stores ParamRef in IRNode.opcode +- macro-enh.E1.3: OPCODE accepted as positional macro argument +- macro-enh.E1.4: Expand pass resolves opcode ParamRef +- macro-enh.E1.5: Full pipeline with opcode params +""" + +from pathlib import Path + +from lark import Lark + +from asm import assemble, run_pipeline +from asm.expand import expand +from asm.lower import lower +from asm.errors import ErrorCategory +from asm.ir import IRNode, ParamRef +from cm_inst import ArithOp, LogicOp, RoutingOp, MemOp, Port + + +def _get_parser(): + grammar_path = Path(__file__).parent.parent / "dfasm.lark" + return Lark( + grammar_path.read_text(), + parser="earley", + propagate_positions=True, + ) + + +def parse_and_lower(source: str): + parser = _get_parser() + tree = parser.parse(source) + return lower(tree) + + +def parse_lower_expand(source: str): + graph = parse_and_lower(source) + return expand(graph) + + +class TestE11_GrammarAcceptsParamRefOpcode: + """E1.1: Grammar accepts param_ref in opcode position.""" + + def test_param_ref_opcode_in_inst_def(self): + """${op} in inst_def opcode position parses and lowers.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + """ + graph = parse_and_lower(source) + assert not graph.errors + # Macro body should have a node with ParamRef opcode + assert len(graph.macro_defs) == 1 + body_nodes = graph.macro_defs[0].body.nodes + assert len(body_nodes) == 1 + node = list(body_nodes.values())[0] + assert isinstance(node.opcode, ParamRef) + assert node.opcode.param == "op" + + def test_param_ref_opcode_in_strong_edge(self): + """${op} in strong_edge opcode position parses and lowers.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + ${op} &src |> &dst + } + """ + graph = parse_and_lower(source) + assert not graph.errors + body_nodes = graph.macro_defs[0].body.nodes + # Strong edge creates anonymous node + anon_nodes = [n for n in body_nodes.values() if isinstance(n.opcode, ParamRef)] + assert len(anon_nodes) == 1 + assert anon_nodes[0].opcode.param == "op" + + def test_param_ref_opcode_in_weak_edge(self): + """${op} in weak_edge opcode position parses and lowers.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &dst ${op} <| &src + } + """ + graph = parse_and_lower(source) + assert not graph.errors + body_nodes = graph.macro_defs[0].body.nodes + anon_nodes = [n for n in body_nodes.values() if isinstance(n.opcode, ParamRef)] + assert len(anon_nodes) == 1 + assert anon_nodes[0].opcode.param == "op" + + +class TestE13_OpcodeAsMacroArgument: + """E1.3: OPCODE accepted as positional macro argument.""" + + def test_bare_opcode_in_macro_call(self): + """#reduce_2 add parses — bare opcode as macro argument.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap add + """ + graph = parse_and_lower(source) + assert not graph.errors + assert len(graph.macro_calls) == 1 + call = graph.macro_calls[0] + assert call.positional_args == ("add",) + + def test_multiple_opcode_args(self): + """Multiple opcodes can be passed as arguments.""" + source = """ + @system pe=1, sm=1 + #pair op1, op2 |> { + &a <| ${op1} + &b <| ${op2} + } + #pair add, sub + """ + graph = parse_and_lower(source) + assert not graph.errors + call = graph.macro_calls[0] + assert call.positional_args == ("add", "sub") + + +class TestE14_ExpandResolvesOpcodeParamRef: + """E1.4: Expand pass resolves opcode ParamRef.""" + + def test_resolve_arith_opcode(self): + """Opcode param 'add' resolves to ArithOp.ADD.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap add + """ + graph = parse_lower_expand(source) + assert not graph.errors + node = list(graph.nodes.values())[0] + assert node.opcode == ArithOp.ADD + + def test_resolve_routing_opcode(self): + """Opcode param 'gate' resolves to RoutingOp.GATE.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap gate + """ + graph = parse_lower_expand(source) + assert not graph.errors + node = list(graph.nodes.values())[0] + assert node.opcode == RoutingOp.GATE + + def test_resolve_mem_opcode(self): + """Opcode param 'read' resolves to MemOp.READ.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap read + """ + graph = parse_lower_expand(source) + assert not graph.errors + node = list(graph.nodes.values())[0] + assert node.opcode == MemOp.READ + + def test_invalid_opcode_mnemonic_error(self): + """Invalid mnemonic produces MACRO error. + + Note: 'banana' lexes as IDENT and parses as a qualified_ref (label_ref &banana), + so we pass it as a qualified_ref dict. The expand pass gets a dict, not a string, + which produces the 'must resolve to an opcode mnemonic' error. + """ + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap &banana + """ + graph = parse_lower_expand(source) + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] + assert len(macro_errors) >= 1 + assert "opcode mnemonic" in macro_errors[0].message + + def test_numeric_opcode_error(self): + """Numeric value as opcode produces MACRO error.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + #wrap 42 + """ + graph = parse_lower_expand(source) + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] + assert len(macro_errors) >= 1 + + +class TestE15_FullPipelineOpcodeParams: + """E1.5: Full pipeline with opcode params.""" + + def test_full_pipeline_opcode_param(self): + """Opcode-parameterized macro assembles through full pipeline.""" + source = """ + @system pe=1, sm=1 + #wrap op |> { + &n <| ${op} + } + &seed <| const, 5 + #wrap add + &seed |> #wrap_0.&n:L + &seed |> #wrap_0.&n:R + """ + result = assemble(source) + assert result is not None + # Should have at least one PE config + assert len(result.pe_configs) >= 1 + + def test_full_pipeline_reduce_pattern(self): + """Reduction tree pattern with opcode param.""" + source = """ + @system pe=1, sm=1 + #reduce_2 op |> { + &r <| ${op} + } + &a <| const, 3 + &b <| const, 7 + #reduce_2 add + &a |> #reduce_2_0.&r:L + &b |> #reduce_2_0.&r:R + """ + result = assemble(source) + assert result is not None -- 2.51.2