OR-1 dataflow CPU sketch

feat(asm): implement macro @ret wiring, parameterized qualifiers, and ${param} substitution

Enhancement 2: Parameterized placement, port, and context slot qualifiers
- Grammar: placement, port, ctx_slot accept param_ref
- IR: PlacementRef, PortRef, CtxSlotRef, CtxSlotRange wrapper types
- Lower: qualified_ref extracts typed qualifier refs; _normalize_port passes PortRef through
- Lower: _process_statements uses replace() instead of manual IRNode reconstruction
- Expand: resolves PlacementRef/PortRef/CtxSlotRef during substitution

Enhancement 3: @ret wiring for macros
- Grammar: macro_call_stmt accepts optional |> call_output_list
- IR: IRMacroCall gains output_dests field
- Lower: macro_call_stmt handler extracts output destinations
- Expand: rewrites @ret/@ret_name edges to concrete destinations after body expansion
- Expand: reports MACRO error for unmatched @ret or missing output wiring

Orual 36bbe7d6 dcac0058

+903 -72
+146 -3
asm/expand.py
··· 19 19 from asm.errors import AssemblyError, ErrorCategory 20 20 from asm.ir import ( 21 21 IRGraph, IRNode, IREdge, IRRegion, RegionKind, ParamRef, ConstExpr, 22 - MacroDef, IRMacroCall, CallSiteResult, CallSite, IRRepetitionBlock, SourceLoc 22 + MacroDef, IRMacroCall, CallSiteResult, CallSite, IRRepetitionBlock, SourceLoc, 23 + PlacementRef, PortRef, CtxSlotRef, CtxSlotRange, 23 24 ) 24 25 from asm.opcodes import MNEMONIC_TO_OP 25 26 from cm_inst import Port, RoutingOp ··· 330 331 )) 331 332 new_opcode = node.opcode 332 333 334 + # Resolve placement if it's a PlacementRef 335 + new_pe = node.pe 336 + if isinstance(new_pe, PlacementRef): 337 + resolved = _substitute_param(new_pe.param, subst_map) 338 + if isinstance(resolved, str) and resolved.startswith("pe"): 339 + try: 340 + new_pe = int(resolved[2:]) 341 + except ValueError: 342 + errors.append(AssemblyError( 343 + loc=node.loc, 344 + category=ErrorCategory.MACRO, 345 + message=f"placement parameter must resolve to 'peN', got '{resolved}'", 346 + )) 347 + new_pe = None 348 + elif isinstance(resolved, int): 349 + new_pe = resolved 350 + else: 351 + errors.append(AssemblyError( 352 + loc=node.loc, 353 + category=ErrorCategory.MACRO, 354 + message=f"placement parameter must resolve to 'peN', got {type(resolved).__name__}", 355 + )) 356 + new_pe = None 357 + 358 + # Resolve ctx_slot if it's a CtxSlotRef 359 + new_ctx_slot = node.ctx_slot 360 + if isinstance(new_ctx_slot, CtxSlotRef): 361 + resolved = _substitute_param(new_ctx_slot.param, subst_map) 362 + if isinstance(resolved, int): 363 + new_ctx_slot = CtxSlotRange(start=resolved, end=resolved) 364 + else: 365 + errors.append(AssemblyError( 366 + loc=node.loc, 367 + category=ErrorCategory.MACRO, 368 + message=f"ctx_slot parameter must resolve to an integer, got {type(resolved).__name__}", 369 + )) 370 + new_ctx_slot = None 371 + 333 372 # Substitute the node name (may be a ParamRef with token pasting) 334 373 substituted_name = _substitute_param(node.name, subst_map) 335 374 ··· 340 379 # Qualify the node name 341 380 new_name = _qualify_expanded_name(substituted_name, macro_scope, parent_scope, func_scope) 342 381 343 - return replace(node, name=new_name, const=new_const, opcode=new_opcode), errors 382 + return replace(node, name=new_name, const=new_const, opcode=new_opcode, 383 + pe=new_pe, ctx_slot=new_ctx_slot), errors 344 384 345 385 346 386 def _clone_and_substitute_edge( ··· 382 422 if not dest_was_param: 383 423 dest = _qualify_expanded_name(dest, macro_scope, parent_scope, func_scope) 384 424 385 - return replace(edge, source=source, dest=dest) 425 + # Resolve PortRef on dest port 426 + new_port = edge.port 427 + if isinstance(new_port, PortRef): 428 + resolved = _substitute_param(new_port.param, subst_map) 429 + if isinstance(resolved, str): 430 + if resolved == "L": 431 + new_port = Port.L 432 + elif resolved == "R": 433 + new_port = Port.R 434 + else: 435 + new_port = Port.L # fallback, error reported elsewhere 436 + elif isinstance(resolved, Port): 437 + new_port = resolved 438 + else: 439 + new_port = Port.L 440 + 441 + # Resolve PortRef on source port 442 + new_source_port = edge.source_port 443 + if isinstance(new_source_port, PortRef): 444 + resolved = _substitute_param(new_source_port.param, subst_map) 445 + if isinstance(resolved, str): 446 + if resolved == "L": 447 + new_source_port = Port.L 448 + elif resolved == "R": 449 + new_source_port = Port.R 450 + else: 451 + new_source_port = None 452 + elif isinstance(resolved, Port): 453 + new_source_port = resolved 454 + else: 455 + new_source_port = None 456 + 457 + return replace(edge, source=source, dest=dest, port=new_port, source_port=new_source_port) 386 458 387 459 388 460 def _add_expansion_context( ··· 655 727 depth, 656 728 ) 657 729 errors.extend(nested_errors) 730 + 731 + # Rewrite @ret edges: either substitute with output destinations, or 732 + # report an error if the macro body uses @ret but the call site doesn't 733 + # provide output wiring. 734 + has_ret_edges = any( 735 + isinstance(e.dest, str) and e.dest.startswith("@ret") 736 + for e in expanded_edges 737 + ) 738 + 739 + if has_ret_edges and not call.output_dests: 740 + # Collect the @ret markers for the error message 741 + ret_markers = sorted({ 742 + e.dest for e in expanded_edges 743 + if isinstance(e.dest, str) and e.dest.startswith("@ret") 744 + }) 745 + errors.append(AssemblyError( 746 + loc=call.loc, 747 + category=ErrorCategory.MACRO, 748 + message=f"macro '#{call.name}' defines output(s) {', '.join(ret_markers)} but call site has no '|>' output wiring", 749 + )) 750 + 751 + if call.output_dests: 752 + rewritten_edges = [] 753 + all_outputs = list(call.output_dests) 754 + 755 + for edge in expanded_edges: 756 + if not (isinstance(edge.dest, str) and edge.dest.startswith("@ret")): 757 + rewritten_edges.append(edge) 758 + continue 759 + 760 + # This edge targets an @ret marker — resolve it 761 + ret_dest = edge.dest # e.g. "@ret" or "@ret_body" 762 + dest_name = None 763 + 764 + # Try named match: @ret_body -> output with name="body" 765 + if ret_dest.startswith("@ret_"): 766 + expected_suffix = ret_dest[5:] # "body" from "@ret_body" 767 + for output in all_outputs: 768 + if isinstance(output, dict) and "name" in output and "ref" in output: 769 + if output["name"] == expected_suffix: 770 + ref = output["ref"] 771 + dest_name = ref.get("name", ref) if isinstance(ref, dict) else str(ref) 772 + break 773 + 774 + # Bare @ret -> first positional output 775 + if dest_name is None and ret_dest == "@ret": 776 + for output in all_outputs: 777 + if isinstance(output, dict): 778 + if "name" in output and "ref" in output: 779 + # Named output — skip for bare @ret 780 + continue 781 + # Positional output (just a ref dict) 782 + dest_name = output.get("name", None) 783 + else: 784 + dest_name = str(output) 785 + if dest_name is not None: 786 + break 787 + 788 + if dest_name is None: 789 + errors.append(AssemblyError( 790 + loc=call.loc, 791 + category=ErrorCategory.MACRO, 792 + message=f"macro '#{call.name}' has output marker '{ret_dest}' but no matching output destination in call site", 793 + )) 794 + rewritten_edges.append(edge) 795 + continue 796 + 797 + # Replace the @ret destination with the concrete node reference 798 + rewritten_edges.append(replace(edge, dest=dest_name)) 799 + 800 + expanded_edges = rewritten_edges 658 801 659 802 # Propagate errors from macro body template 660 803 for body_error in macro_def.body.errors:
+30 -3
asm/ir.py
··· 82 82 dest_l: Optional[Union[NameRef, ResolvedDest]] = None 83 83 dest_r: Optional[Union[NameRef, ResolvedDest]] = None 84 84 const: Optional[Union[int, ParamRef, ConstExpr]] = None 85 - pe: Optional[int] = None 85 + pe: Optional[Union[int, PlacementRef]] = None 86 + ctx_slot: Optional[Union[int, CtxSlotRef, CtxSlotRange]] = None 86 87 iram_offset: Optional[int] = None 87 88 ctx: Optional[int] = None 88 89 loc: SourceLoc = SourceLoc(0, 0) ··· 106 107 """ 107 108 source: Union[str, ParamRef] 108 109 dest: Union[str, ParamRef] 109 - port: Port 110 - source_port: Optional[Port] = None 110 + port: Union[Port, PortRef] 111 + source_port: Optional[Union[Port, PortRef]] = None 111 112 port_explicit: bool = False 112 113 ctx_override: bool = False 113 114 loc: SourceLoc = SourceLoc(0, 0) ··· 169 170 170 171 171 172 @dataclass(frozen=True) 173 + class PlacementRef: 174 + """Deferred placement from macro parameter.""" 175 + param: ParamRef 176 + 177 + 178 + @dataclass(frozen=True) 179 + class PortRef: 180 + """Deferred port from macro parameter.""" 181 + param: ParamRef 182 + 183 + 184 + @dataclass(frozen=True) 185 + class CtxSlotRef: 186 + """Deferred context slot from macro parameter.""" 187 + param: ParamRef 188 + 189 + 190 + @dataclass(frozen=True) 191 + class CtxSlotRange: 192 + """Explicit context slot range reservation.""" 193 + start: int 194 + end: int 195 + 196 + 197 + @dataclass(frozen=True) 172 198 class IRRepetitionBlock: 173 199 """A repetition block within a macro body template. 174 200 ··· 241 267 name: str 242 268 positional_args: tuple = () 243 269 named_args: tuple[tuple[str, object], ...] = () 270 + output_dests: tuple = () 244 271 loc: SourceLoc = SourceLoc(0, 0) 245 272 246 273
+75 -60
asm/lower.py
··· 20 20 from asm.ir import ( 21 21 IRGraph, IRNode, IREdge, IRRegion, RegionKind, IRDataDef, SystemConfig, 22 22 SourceLoc, NameRef, ResolvedDest, MacroParam, ParamRef, MacroDef, IRMacroCall, 23 - CallSiteResult, IRRepetitionBlock 23 + CallSiteResult, IRRepetitionBlock, 24 + PlacementRef, PortRef, CtxSlotRef, CtxSlotRange, 24 25 ) 25 26 from asm.errors import AssemblyError, ErrorCategory 26 27 from asm.opcodes import MNEMONIC_TO_OP ··· 38 39 return [arg for arg in args if not isinstance(arg, LarkToken)] 39 40 40 41 41 - def _normalize_port(value: Union[int, Port]) -> Port: 42 - """Normalize a port value to Port enum. 43 - 44 - Handles conversion from raw integers (0 for L, 1 for R) to Port enum. 45 - If already a Port, returns as-is. Defaults to Port.L for invalid values. 42 + def _normalize_port(value: Union[int, Port, PortRef]) -> Union[Port, PortRef]: 43 + """Normalize a port value to Port enum, preserving PortRef for macro templates. 46 44 47 45 Args: 48 - value: An int (0/1) or Port enum value 46 + value: An int (0/1), Port enum, or PortRef (macro parameter) 49 47 50 48 Returns: 51 - Normalized Port enum value 49 + Port enum value, or PortRef passed through for later expansion 52 50 """ 51 + if isinstance(value, PortRef): 52 + return value 53 53 if isinstance(value, Port): 54 54 return value 55 55 if isinstance(value, int): ··· 213 213 qualified_name = self._qualify_name(node_name, func_scope) 214 214 if not self._check_duplicate_name(qualified_name, node.loc): 215 215 # Update node with qualified name 216 - qualified_node = IRNode( 217 - name=qualified_name, 218 - opcode=node.opcode, 219 - dest_l=node.dest_l, 220 - dest_r=node.dest_r, 221 - const=node.const, 222 - pe=node.pe, 223 - iram_offset=node.iram_offset, 224 - ctx=node.ctx, 225 - loc=node.loc, 226 - args=node.args, 227 - sm_id=node.sm_id, 228 - seed=node.seed, 229 - ) 216 + qualified_node = replace(node, name=qualified_name) 230 217 nodes[qualified_name] = qualified_node 231 218 232 219 elif isinstance(stmt, EdgeResult): ··· 246 233 for node_name, node in stmt.nodes.items(): 247 234 qualified_name = self._qualify_name(node_name, func_scope) 248 235 if not self._check_duplicate_name(qualified_name, node.loc): 249 - qualified_node = IRNode( 250 - name=qualified_name, 251 - opcode=node.opcode, 252 - dest_l=node.dest_l, 253 - dest_r=node.dest_r, 254 - const=node.const, 255 - pe=node.pe, 256 - iram_offset=node.iram_offset, 257 - ctx=node.ctx, 258 - loc=node.loc, 259 - args=node.args, 260 - sm_id=node.sm_id, 261 - seed=node.seed, 262 - ) 236 + qualified_node = replace(node, name=qualified_name) 263 237 nodes[qualified_name] = qualified_node 264 238 for edge in stmt.edges: 265 239 qualified_edge = IREdge( ··· 430 404 # Extract placement (PE qualifier) 431 405 pe = None 432 406 if "placement" in qualified_ref_dict and qualified_ref_dict["placement"]: 433 - placement_str = qualified_ref_dict["placement"] 434 - # Parse placement like "pe0" → extract 0 435 - if placement_str.startswith("pe"): 407 + placement_val = qualified_ref_dict["placement"] 408 + if isinstance(placement_val, PlacementRef): 409 + pe = placement_val 410 + elif isinstance(placement_val, str) and placement_val.startswith("pe"): 436 411 try: 437 - pe = int(placement_str[2:]) 412 + pe = int(placement_val[2:]) 438 413 except ValueError: 439 414 pass 415 + 416 + # Extract context slot qualifier 417 + ctx_slot = qualified_ref_dict.get("ctx_slot") 440 418 441 419 # Extract const and named args from arguments 442 420 # Check if first remaining arg is an inline_const (int directly after opcode) ··· 465 443 dest_r=None, 466 444 const=const, 467 445 pe=pe, 446 + ctx_slot=ctx_slot, 468 447 loc=loc, 469 448 args=args_dict if args_dict else None, 470 449 ) 471 - 472 450 return NodeResult({name: node}) 473 451 474 452 @v_args(inline=True, meta=True) ··· 977 955 978 956 positional_args = [] 979 957 named_args: dict[str, object] = {} 958 + output_dests = () 980 959 found_name = False 981 960 for item in args: 982 961 if isinstance(item, LarkToken): ··· 984 963 # First LarkToken is the macro name 985 964 found_name = True 986 965 continue 987 - if item.type == "OPCODE": 988 - # Bare opcode token as macro argument — wrap as string 966 + if item.type in ("OPCODE", "IDENT"): 967 + # Bare opcode or identifier as macro argument — wrap as string 989 968 positional_args.append(str(item)) 990 969 continue 991 970 # Skip other tokens (FLOW_OUT, commas, etc.) 992 971 continue 972 + elif isinstance(item, list) and all(isinstance(x, dict) for x in item): 973 + # call_output_list result — list of output dest dicts 974 + output_dests = tuple(item) 993 975 elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], str): 994 976 # Named argument from named_arg rule (name, value) 995 977 named_args[item[0]] = item[1] ··· 1004 986 name=macro_name, 1005 987 positional_args=tuple(positional_args), 1006 988 named_args=tuple(named_args.items()), 989 + output_dests=output_dests, 1007 990 loc=loc, 1008 991 ) 1009 992 ··· 1121 1104 # Extract SM ID from placement 1122 1105 sm_id = None 1123 1106 if "placement" in qualified_ref_dict and qualified_ref_dict["placement"]: 1124 - placement_str = qualified_ref_dict["placement"] 1125 - if placement_str.startswith("sm"): 1107 + placement_val = qualified_ref_dict["placement"] 1108 + if isinstance(placement_val, str) and placement_val.startswith("sm"): 1126 1109 try: 1127 - sm_id = int(placement_str[2:]) 1110 + sm_id = int(placement_val[2:]) 1128 1111 except ValueError: 1129 1112 pass 1130 1113 ··· 1271 1254 """Collect qualified reference components into a dict.""" 1272 1255 ref_type = None 1273 1256 placement = None 1257 + ctx_slot = None 1274 1258 port = None 1275 1259 1276 1260 for arg in args: 1277 - if isinstance(arg, ParamRef): 1261 + if isinstance(arg, PlacementRef): 1262 + placement = arg 1263 + elif isinstance(arg, PortRef): 1264 + port = arg 1265 + elif isinstance(arg, (CtxSlotRef, CtxSlotRange)): 1266 + ctx_slot = arg 1267 + elif isinstance(arg, (Port, int)): 1268 + port = arg 1269 + elif isinstance(arg, ParamRef): 1278 1270 ref_type = {"name": arg} 1279 1271 elif isinstance(arg, dict): 1280 1272 ref_type = arg 1281 1273 elif isinstance(arg, str) and (arg.startswith("pe") or arg.startswith("sm")): 1282 1274 placement = arg 1283 - elif isinstance(arg, (Port, int)): 1284 - port = arg 1285 1275 1286 1276 result = ref_type.copy() if ref_type else {} 1287 - if placement: 1277 + if placement is not None: 1288 1278 result["placement"] = placement 1279 + if ctx_slot is not None: 1280 + result["ctx_slot"] = ctx_slot 1289 1281 if port is not None: 1290 1282 result["port"] = port 1291 1283 ··· 1316 1308 return ParamRef(param=name) 1317 1309 1318 1310 @v_args(inline=True) 1319 - def placement(self, token: LarkToken) -> str: 1311 + def placement(self, token) -> Union[str, PlacementRef]: 1320 1312 """Extract placement specifier.""" 1313 + if isinstance(token, ParamRef): 1314 + return PlacementRef(param=token) 1321 1315 return str(token) 1322 1316 1317 + def ctx_slot(self, args: list): 1318 + """Extract context slot specifier. 1319 + 1320 + Always returns a typed wrapper (CtxSlotRef, CtxSlotRange) so 1321 + qualified_ref can distinguish ctx_slot ints from port ints. 1322 + """ 1323 + if len(args) == 1: 1324 + arg = args[0] 1325 + if isinstance(arg, ParamRef): 1326 + return CtxSlotRef(param=arg) 1327 + if isinstance(arg, CtxSlotRange): 1328 + return arg 1329 + n = int(str(arg)) 1330 + return CtxSlotRange(start=n, end=n) 1331 + return args[0] 1332 + 1333 + def ctx_range(self, args: list) -> CtxSlotRange: 1334 + """Extract context slot range (start..end).""" 1335 + return CtxSlotRange(start=int(str(args[0])), end=int(str(args[1]))) 1336 + 1323 1337 @v_args(inline=True) 1324 - def port(self, token: LarkToken) -> Union[Port, int]: 1325 - """Convert port specifier to Port enum or raw int. 1338 + def port(self, token) -> Union[Port, int, PortRef]: 1339 + """Convert port specifier to Port enum, raw int, or PortRef. 1326 1340 1327 1341 Returns: 1328 - Port.L for "L" or "0" 1329 - Port.R for "R" or "1" 1330 - Raw int for other numeric values (e.g., cell address) 1342 + Port.L for "L" 1343 + Port.R for "R" 1344 + Raw int for numeric values (e.g., cell address in data_def) 1345 + PortRef for param_ref 1331 1346 """ 1347 + if isinstance(token, ParamRef): 1348 + return PortRef(param=token) 1332 1349 spec = str(token) 1333 - if spec in ("L", "0"): 1350 + if spec == "L": 1334 1351 return Port.L 1335 - elif spec in ("R", "1"): 1352 + elif spec == "R": 1336 1353 return Port.R 1337 1354 else: 1338 - # Try to parse as integer (for cell addresses in data_def) 1339 1355 try: 1340 1356 return int(spec) 1341 1357 except ValueError: 1342 - # Fall back to Port.L for unparseable values 1343 1358 return Port.L 1344 1359 1345 1360 @v_args(inline=True)
+8 -6
dfasm.lark
··· 78 78 // #name — macro reference 79 79 // Chaining: @sum|pe0:L (placement + port) 80 80 81 - qualified_ref: (node_ref | label_ref | func_ref | macro_ref | scoped_ref | param_ref) placement? port? 81 + qualified_ref: (node_ref | label_ref | func_ref | macro_ref | scoped_ref | param_ref) placement? ctx_slot? port? 82 82 83 83 node_ref: "@" IDENT 84 84 label_ref: "&" IDENT ··· 91 91 param_ref: PARAM_REF_START IDENT "}" 92 92 PARAM_REF_START.3: "${" 93 93 94 - placement: "|" IDENT 95 - port: ":" PORT_SPEC 94 + placement: "|" (IDENT | param_ref) 95 + ctx_slot: "[" (DEC_LIT | ctx_range | param_ref) "]" 96 + ctx_range: DEC_LIT ".." DEC_LIT 97 + port: ":" (PORT_SPEC | param_ref) 96 98 97 99 PORT_SPEC: IDENT | HEX_LIT | DEC_LIT 98 100 ··· 103 105 104 106 ?argument: named_arg | positional_arg 105 107 named_arg: IDENT "=" positional_arg 106 - ?positional_arg: value | qualified_ref | OPCODE 108 + ?positional_arg: value | qualified_ref | OPCODE | IDENT 107 109 108 110 // === Values (literals) === 109 111 ··· 121 123 122 124 macro_call: "#" IDENT (value | qualified_ref)* 123 125 124 - // #name arg [, arg ...] — standalone macro invocation (as statement) 125 - macro_call_stmt: "#" IDENT (argument ("," argument)*)? 126 + // #name arg [, arg ...] [|> output, ...] — standalone macro invocation (as statement) 127 + macro_call_stmt: "#" IDENT (argument ("," argument)*)? (FLOW_OUT call_output_list)? 126 128 127 129 // --- Function call --- 128 130 // $func a=&x, b=&y |> @output [, name=@output2]
+338
tests/test_macro_ret_wiring.py
··· 1 + """Tests for Enhancement 3: @ret wiring for macros (macro-enh.E3.*). 2 + 3 + Tests verify: 4 + - E3.1: Grammar accepts |> output list on macro_call_stmt 5 + - E3.2: Lower stores output_dests on IRMacroCall 6 + - E3.3: Expand rewrites @ret edges to concrete destinations (positional) 7 + - E3.4: Expand rewrites @ret_name edges to concrete destinations (named) 8 + - E3.5: Unmatched @ret marker produces MACRO error 9 + - E3.6: Macro with @ret but no |> at call site produces MACRO error 10 + - E3.7: Multiple @ret markers with mixed positional/named outputs 11 + - E3.8: @ret in nested macro invocation 12 + """ 13 + 14 + from pathlib import Path 15 + 16 + from lark import Lark 17 + 18 + from asm.expand import expand 19 + from asm.lower import lower 20 + from asm.errors import ErrorCategory 21 + from asm.ir import IRMacroCall 22 + 23 + 24 + def _get_parser(): 25 + grammar_path = Path(__file__).parent.parent / "dfasm.lark" 26 + return Lark( 27 + grammar_path.read_text(), 28 + parser="earley", 29 + propagate_positions=True, 30 + ) 31 + 32 + 33 + def parse_and_lower(source: str): 34 + parser = _get_parser() 35 + tree = parser.parse(source) 36 + return lower(tree) 37 + 38 + 39 + def parse_lower_expand(source: str): 40 + graph = parse_and_lower(source) 41 + return expand(graph) 42 + 43 + 44 + class TestE31_GrammarAcceptsOutputList: 45 + """E3.1: Grammar accepts |> output list on macro_call_stmt. 46 + 47 + Note: #macro |> &dest (no args, single positional output) is ambiguous 48 + with plain_edge syntax and parses as plain_edge. Disambiguation requires 49 + either named outputs (name=&dest) or at least one argument before |>. 50 + """ 51 + 52 + def test_macro_call_with_named_output_no_args(self): 53 + """#macro |> name=&dest parses as macro_call_stmt (named output disambiguates).""" 54 + source = """ 55 + @system pe=1, sm=1 56 + #simple |> { 57 + &g <| pass 58 + &g |> @ret_out 59 + } 60 + &sink <| add 61 + #simple |> out=&sink 62 + """ 63 + graph = parse_and_lower(source) 64 + assert not graph.errors 65 + assert len(graph.macro_calls) == 1 66 + 67 + def test_macro_call_with_named_outputs(self): 68 + """#macro |> name=&dest, name2=&dest2 parses without error.""" 69 + source = """ 70 + @system pe=1, sm=1 71 + #dual |> { 72 + &g <| pass 73 + &g |> @ret_body 74 + &g |> @ret_exit:R 75 + } 76 + &body_sink <| add 77 + &exit_sink <| add 78 + #dual |> body=&body_sink, exit=&exit_sink 79 + """ 80 + graph = parse_and_lower(source) 81 + assert not graph.errors 82 + 83 + def test_macro_call_with_args_and_positional_output(self): 84 + """#macro arg |> &dest parses as macro_call_stmt (args disambiguate).""" 85 + source = """ 86 + @system pe=1, sm=1 87 + #with_arg val |> { 88 + &g <| const, ${val} 89 + &g |> @ret 90 + } 91 + &sink <| add 92 + #with_arg 42 |> &sink 93 + """ 94 + graph = parse_and_lower(source) 95 + assert not graph.errors 96 + assert len(graph.macro_calls) == 1 97 + 98 + 99 + class TestE32_LowerStoresOutputDests: 100 + """E3.2: Lower stores output_dests on IRMacroCall.""" 101 + 102 + def test_output_dests_populated_with_arg(self): 103 + """IRMacroCall.output_dests contains output destinations (arg disambiguates).""" 104 + source = """ 105 + @system pe=1, sm=1 106 + #simple val |> { 107 + &g <| const, ${val} 108 + &g |> @ret 109 + } 110 + &sink <| add 111 + #simple 1 |> &sink 112 + """ 113 + graph = parse_and_lower(source) 114 + assert not graph.errors 115 + assert len(graph.macro_calls) == 1 116 + call = graph.macro_calls[0] 117 + assert len(call.output_dests) >= 1 118 + 119 + def test_named_output_dests(self): 120 + """Named outputs stored as {"name": ..., "ref": ...} dicts.""" 121 + source = """ 122 + @system pe=1, sm=1 123 + #dual |> { 124 + &g <| pass 125 + } 126 + &sink <| add 127 + #dual |> body=&sink 128 + """ 129 + graph = parse_and_lower(source) 130 + assert not graph.errors 131 + call = graph.macro_calls[0] 132 + assert len(call.output_dests) >= 1 133 + output = call.output_dests[0] 134 + assert isinstance(output, dict) 135 + assert output.get("name") == "body" 136 + 137 + 138 + class TestE33_ExpandRewritesPositionalRet: 139 + """E3.3: Expand rewrites @ret edges to concrete destinations (positional).""" 140 + 141 + def test_bare_ret_rewritten(self): 142 + """@ret in macro body becomes concrete &sink after expansion.""" 143 + source = """ 144 + @system pe=1, sm=1 145 + #simple val |> { 146 + &g <| const, ${val} 147 + &g |> @ret 148 + } 149 + &sink <| add 150 + #simple 1 |> &sink 151 + """ 152 + graph = parse_lower_expand(source) 153 + assert not graph.errors 154 + # Find the edge from the expanded &g to &sink 155 + ret_edges = [ 156 + e for e in graph.edges 157 + if e.dest == "&sink" and "#simple_0.&g" in e.source 158 + ] 159 + assert len(ret_edges) == 1 160 + 161 + def test_bare_ret_with_named_output(self): 162 + """@ret in macro body resolved via named output (no args needed).""" 163 + source = """ 164 + @system pe=1, sm=1 165 + #simple |> { 166 + &g <| pass 167 + &g |> @ret_out 168 + } 169 + &sink <| add 170 + #simple |> out=&sink 171 + """ 172 + graph = parse_lower_expand(source) 173 + assert not graph.errors 174 + ret_edges = [ 175 + e for e in graph.edges 176 + if e.dest == "&sink" and "#simple_0.&g" in e.source 177 + ] 178 + assert len(ret_edges) == 1 179 + 180 + 181 + class TestE34_ExpandRewritesNamedRet: 182 + """E3.4: Expand rewrites @ret_name edges to concrete destinations (named).""" 183 + 184 + def test_named_ret_body(self): 185 + """@ret_body in macro resolves to body=&body_sink output.""" 186 + source = """ 187 + @system pe=1, sm=1 188 + #dual |> { 189 + &g <| pass 190 + &g |> @ret_body 191 + &g |> @ret_exit:R 192 + } 193 + &body_sink <| add 194 + &exit_sink <| add 195 + #dual |> body=&body_sink, exit=&exit_sink 196 + """ 197 + graph = parse_lower_expand(source) 198 + assert not graph.errors 199 + # Find edges from expanded #dual_0.&g 200 + g_edges = [e for e in graph.edges if "#dual_0.&g" in e.source] 201 + dests = {e.dest for e in g_edges} 202 + assert "&body_sink" in dests 203 + assert "&exit_sink" in dests 204 + 205 + def test_mixed_named_outputs(self): 206 + """Multiple @ret_name markers all resolve correctly.""" 207 + source = """ 208 + @system pe=1, sm=1 209 + #triple |> { 210 + &a <| pass 211 + &b <| pass 212 + &c <| pass 213 + &a |> @ret_x 214 + &b |> @ret_y 215 + &c |> @ret_z 216 + } 217 + &x_sink <| add 218 + &y_sink <| add 219 + &z_sink <| add 220 + #triple |> x=&x_sink, y=&y_sink, z=&z_sink 221 + """ 222 + graph = parse_lower_expand(source) 223 + assert not graph.errors 224 + all_dests = {e.dest for e in graph.edges} 225 + assert "&x_sink" in all_dests 226 + assert "&y_sink" in all_dests 227 + assert "&z_sink" in all_dests 228 + 229 + 230 + class TestE35_UnmatchedRetError: 231 + """E3.5: Unmatched @ret marker produces MACRO error.""" 232 + 233 + def test_unmatched_named_ret(self): 234 + """@ret_missing has no matching named output -> MACRO error.""" 235 + source = """ 236 + @system pe=1, sm=1 237 + #bad |> { 238 + &g <| pass 239 + &g |> @ret_missing 240 + } 241 + &sink <| add 242 + #bad |> wrong_name=&sink 243 + """ 244 + graph = parse_lower_expand(source) 245 + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] 246 + assert len(macro_errors) >= 1 247 + assert "@ret_missing" in macro_errors[0].message 248 + 249 + def test_extra_ret_marker(self): 250 + """Macro has @ret_body and @ret_exit but call only provides body output.""" 251 + source = """ 252 + @system pe=1, sm=1 253 + #dual |> { 254 + &a <| pass 255 + &b <| pass 256 + &a |> @ret_body 257 + &b |> @ret_exit 258 + } 259 + &sink <| add 260 + #dual |> body=&sink 261 + """ 262 + graph = parse_lower_expand(source) 263 + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] 264 + assert len(macro_errors) >= 1 265 + assert "@ret_exit" in macro_errors[0].message 266 + 267 + 268 + class TestE36_NoOutputWiringError: 269 + """E3.6: Macro with @ret but no |> at call site produces MACRO error.""" 270 + 271 + def test_ret_without_output_wiring(self): 272 + """Macro body uses @ret but call has no |> -> error.""" 273 + source = """ 274 + @system pe=1, sm=1 275 + #needs_output |> { 276 + &g <| pass 277 + &g |> @ret 278 + } 279 + #needs_output 280 + """ 281 + graph = parse_lower_expand(source) 282 + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] 283 + assert len(macro_errors) >= 1 284 + assert "output" in macro_errors[0].message.lower() or "@ret" in macro_errors[0].message 285 + 286 + 287 + class TestE37_MultipleRetMarkers: 288 + """E3.7: Multiple @ret markers with mixed positional/named outputs.""" 289 + 290 + def test_named_and_positional_ret(self): 291 + """Macro with @ret_main and @ret_extra resolves to named outputs.""" 292 + source = """ 293 + @system pe=1, sm=1 294 + #mixed |> { 295 + &a <| pass 296 + &b <| pass 297 + &a |> @ret_main 298 + &b |> @ret_extra 299 + } 300 + &main_sink <| add 301 + &extra_sink <| add 302 + #mixed |> main=&main_sink, extra=&extra_sink 303 + """ 304 + graph = parse_lower_expand(source) 305 + assert not graph.errors 306 + all_dests = {e.dest for e in graph.edges} 307 + assert "&main_sink" in all_dests 308 + assert "&extra_sink" in all_dests 309 + 310 + 311 + class TestE38_NestedMacroRet: 312 + """E3.8: @ret in nested macro invocation.""" 313 + 314 + def test_nested_macro_ret_resolves(self): 315 + """Inner macro's @ret resolves independently from outer macro's @ret.""" 316 + source = """ 317 + @system pe=1, sm=1 318 + #inner val |> { 319 + &i <| const, ${val} 320 + &i |> @ret 321 + } 322 + #outer val |> { 323 + &o <| pass 324 + #inner ${val} |> &o 325 + &o |> @ret_out 326 + } 327 + &final_sink <| add 328 + #outer 1 |> out=&final_sink 329 + """ 330 + graph = parse_lower_expand(source) 331 + assert not graph.errors 332 + # The outer @ret_out should resolve to &final_sink 333 + # The inner @ret should resolve to #outer_0.&o 334 + outer_ret_edges = [ 335 + e for e in graph.edges 336 + if e.dest == "&final_sink" 337 + ] 338 + assert len(outer_ret_edges) >= 1
+306
tests/test_qualified_ref_params.py
··· 1 + """Tests for Enhancement 2: Parameterized placement and port qualifiers (macro-enh.E2.*). 2 + 3 + Tests verify: 4 + - macro-enh.E2.1: Grammar accepts param_ref in placement position 5 + - macro-enh.E2.2: Grammar accepts param_ref in port position 6 + - macro-enh.E2.3: Context slot bracket syntax parses 7 + - macro-enh.E2.4: Expand pass resolves placement ParamRef 8 + - macro-enh.E2.5: Expand pass resolves port ParamRef 9 + - macro-enh.E2.6: Expand pass resolves context slot ParamRef 10 + - macro-enh.E2.7: Full pipeline with placement/port params 11 + 12 + Note: Placement qualifiers attach to the LHS qualified_ref in inst_def, 13 + not to the opcode. So `&n|${pe} <| add` is the correct syntax, not 14 + `&n <| add|${pe}`. 15 + """ 16 + 17 + from pathlib import Path 18 + 19 + from lark import Lark 20 + 21 + from asm import assemble, run_pipeline 22 + from asm.expand import expand 23 + from asm.lower import lower 24 + from asm.errors import ErrorCategory 25 + from asm.ir import ( 26 + IRNode, ParamRef, PlacementRef, PortRef, CtxSlotRef, CtxSlotRange, 27 + ) 28 + from cm_inst import ArithOp, Port 29 + 30 + 31 + def _get_parser(): 32 + grammar_path = Path(__file__).parent.parent / "dfasm.lark" 33 + return Lark( 34 + grammar_path.read_text(), 35 + parser="earley", 36 + propagate_positions=True, 37 + ) 38 + 39 + 40 + def parse_and_lower(source: str): 41 + parser = _get_parser() 42 + tree = parser.parse(source) 43 + return lower(tree) 44 + 45 + 46 + def parse_lower_expand(source: str): 47 + graph = parse_and_lower(source) 48 + return expand(graph) 49 + 50 + 51 + class TestE21_PlacementParamRef: 52 + """E2.1: Grammar accepts param_ref in placement position.""" 53 + 54 + def test_placement_param_ref_in_macro_body(self): 55 + """&n|${pe} <| add parses and lowers to PlacementRef.""" 56 + source = """ 57 + @system pe=1, sm=1 58 + #place pe |> { 59 + &n|${pe} <| add 60 + } 61 + """ 62 + graph = parse_and_lower(source) 63 + assert not graph.errors 64 + body_nodes = graph.macro_defs[0].body.nodes 65 + node = list(body_nodes.values())[0] 66 + assert isinstance(node.pe, PlacementRef) 67 + assert node.pe.param.param == "pe" 68 + 69 + 70 + class TestE22_PortParamRef: 71 + """E2.2: Grammar accepts param_ref in port position.""" 72 + 73 + def test_port_param_ref_in_edge(self): 74 + """&src |> &dst:${port} parses and lowers to PortRef.""" 75 + source = """ 76 + @system pe=1, sm=1 77 + #wire port |> { 78 + &src <| pass 79 + &dst <| add 80 + &src |> &dst:${port} 81 + } 82 + """ 83 + graph = parse_and_lower(source) 84 + assert not graph.errors 85 + body_edges = graph.macro_defs[0].body.edges 86 + port_ref_edges = [e for e in body_edges if isinstance(e.port, PortRef)] 87 + assert len(port_ref_edges) == 1 88 + assert port_ref_edges[0].port.param.param == "port" 89 + 90 + 91 + class TestE23_CtxSlotSyntax: 92 + """E2.3: Context slot bracket syntax parses.""" 93 + 94 + def test_literal_ctx_slot(self): 95 + """&node[2] parses — literal context slot in bracket syntax.""" 96 + source = """ 97 + @system pe=1, sm=1 98 + #slot_macro |> { 99 + &n[2] <| add 100 + } 101 + """ 102 + graph = parse_and_lower(source) 103 + assert not graph.errors 104 + body_nodes = graph.macro_defs[0].body.nodes 105 + node = list(body_nodes.values())[0] 106 + assert isinstance(node.ctx_slot, CtxSlotRange) 107 + assert node.ctx_slot.start == 2 108 + assert node.ctx_slot.end == 2 109 + 110 + def test_full_qualifier_chain(self): 111 + """&node|pe0[2]:L parses — placement + ctx_slot + port.""" 112 + source = """ 113 + @system pe=1, sm=1 114 + &n|pe0[2]:L <| add 115 + """ 116 + graph = parse_and_lower(source) 117 + assert not graph.errors 118 + node = list(graph.nodes.values())[0] 119 + assert node.pe == 0 120 + assert isinstance(node.ctx_slot, CtxSlotRange) 121 + assert node.ctx_slot.start == 2 122 + assert node.ctx_slot.end == 2 123 + 124 + def test_parameterized_ctx_slot(self): 125 + """&node[${ctx}] parses — parameterized context slot.""" 126 + source = """ 127 + @system pe=1, sm=1 128 + #ctx_macro ctx |> { 129 + &n[${ctx}] <| add 130 + } 131 + """ 132 + graph = parse_and_lower(source) 133 + assert not graph.errors 134 + body_nodes = graph.macro_defs[0].body.nodes 135 + node = list(body_nodes.values())[0] 136 + assert isinstance(node.ctx_slot, CtxSlotRef) 137 + assert node.ctx_slot.param.param == "ctx" 138 + 139 + def test_ctx_slot_range(self): 140 + """&node[0..4] parses — range reservation.""" 141 + source = """ 142 + @system pe=1, sm=1 143 + &n[0..4] <| add 144 + """ 145 + graph = parse_and_lower(source) 146 + assert not graph.errors 147 + node = list(graph.nodes.values())[0] 148 + assert isinstance(node.ctx_slot, CtxSlotRange) 149 + assert node.ctx_slot.start == 0 150 + assert node.ctx_slot.end == 4 151 + 152 + 153 + class TestE24_ExpandResolvesPlacement: 154 + """E2.4: Expand pass resolves placement ParamRef.""" 155 + 156 + def test_resolve_pe0(self): 157 + """Placement param 'pe0' resolves to PE 0.""" 158 + source = """ 159 + @system pe=2, sm=1 160 + #place pe |> { 161 + &n|${pe} <| add 162 + } 163 + #place pe0 164 + """ 165 + graph = parse_lower_expand(source) 166 + assert not graph.errors 167 + node = list(graph.nodes.values())[0] 168 + assert node.pe == 0 169 + 170 + def test_resolve_pe1(self): 171 + """Placement param 'pe1' resolves to PE 1.""" 172 + source = """ 173 + @system pe=2, sm=1 174 + #place pe |> { 175 + &n|${pe} <| add 176 + } 177 + #place pe1 178 + """ 179 + graph = parse_lower_expand(source) 180 + assert not graph.errors 181 + node = list(graph.nodes.values())[0] 182 + assert node.pe == 1 183 + 184 + def test_invalid_placement_error(self): 185 + """Invalid placement value produces MACRO error.""" 186 + source = """ 187 + @system pe=1, sm=1 188 + #place pe |> { 189 + &n|${pe} <| add 190 + } 191 + #place &banana 192 + """ 193 + graph = parse_lower_expand(source) 194 + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] 195 + assert len(macro_errors) >= 1 196 + assert "placement" in macro_errors[0].message.lower() 197 + 198 + 199 + class TestE25_ExpandResolvesPort: 200 + """E2.5: Expand pass resolves port ParamRef.""" 201 + 202 + def test_resolve_port_L(self): 203 + """Port param 'L' resolves to Port.L.""" 204 + source = """ 205 + @system pe=1, sm=1 206 + #wire port |> { 207 + &src <| pass 208 + &dst <| add 209 + &src |> &dst:${port} 210 + } 211 + #wire L 212 + """ 213 + graph = parse_lower_expand(source) 214 + assert not graph.errors 215 + edges = [e for e in graph.edges if e.dest.endswith("&dst")] 216 + assert len(edges) >= 1 217 + assert edges[0].port == Port.L 218 + 219 + def test_resolve_port_R(self): 220 + """Port param 'R' resolves to Port.R.""" 221 + source = """ 222 + @system pe=1, sm=1 223 + #wire port |> { 224 + &src <| pass 225 + &dst <| add 226 + &src |> &dst:${port} 227 + } 228 + #wire R 229 + """ 230 + graph = parse_lower_expand(source) 231 + assert not graph.errors 232 + edges = [e for e in graph.edges if e.dest.endswith("&dst")] 233 + assert len(edges) >= 1 234 + assert edges[0].port == Port.R 235 + 236 + 237 + class TestE26_ExpandResolvesCtxSlot: 238 + """E2.6: Expand pass resolves context slot ParamRef.""" 239 + 240 + def test_resolve_ctx_slot_int(self): 241 + """Ctx slot param resolves to CtxSlotRange.""" 242 + source = """ 243 + @system pe=1, sm=1 244 + #ctx_macro ctx |> { 245 + &n[${ctx}] <| add 246 + } 247 + #ctx_macro 2 248 + """ 249 + graph = parse_lower_expand(source) 250 + assert not graph.errors 251 + node = list(graph.nodes.values())[0] 252 + assert isinstance(node.ctx_slot, CtxSlotRange) 253 + assert node.ctx_slot.start == 2 254 + assert node.ctx_slot.end == 2 255 + 256 + def test_non_numeric_ctx_slot_error(self): 257 + """Non-numeric ctx slot value produces MACRO error.""" 258 + source = """ 259 + @system pe=1, sm=1 260 + #ctx_macro ctx |> { 261 + &n[${ctx}] <| add 262 + } 263 + #ctx_macro &banana 264 + """ 265 + graph = parse_lower_expand(source) 266 + macro_errors = [e for e in graph.errors if e.category == ErrorCategory.MACRO] 267 + assert len(macro_errors) >= 1 268 + assert "ctx_slot" in macro_errors[0].message.lower() 269 + 270 + 271 + class TestE27_FullPipeline: 272 + """E2.7: Full pipeline with placement/port params.""" 273 + 274 + def test_full_pipeline_placement_param(self): 275 + """Placement-parameterized macro assembles; node on correct PE.""" 276 + source = """ 277 + @system pe=2, sm=1 278 + #place pe |> { 279 + &n|${pe} <| add 280 + } 281 + &seed <| const, 5 282 + #place pe1 283 + &seed |> #place_0.&n:L 284 + &seed |> #place_0.&n:R 285 + """ 286 + graph = run_pipeline(source) 287 + assert not graph.errors 288 + placed_node = graph.nodes["#place_0.&n"] 289 + assert placed_node.pe == 1 290 + 291 + def test_full_pipeline_port_param(self): 292 + """Port-parameterized macro assembles; edge targets correct port.""" 293 + source = """ 294 + @system pe=1, sm=1 295 + #wire port |> { 296 + &src <| pass 297 + &dst <| add 298 + &src |> &dst:${port} 299 + } 300 + &seed <| const, 5 301 + #wire L 302 + &seed |> #wire_0.&src 303 + &seed |> #wire_0.&dst:R 304 + """ 305 + result = assemble(source) 306 + assert result is not None