From bf838867e6d7fbfd42036977d42ab7c6457292bf Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 24 Feb 2026 03:48:18 +0000 Subject: [PATCH] feat: add opcode-to-category mapping for dfgraph --- dfgraph/categories.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_dfgraph_categories.py | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 250 insertion(s)(+), 0 deletion(s)(-) diff --git a/dfgraph/categories.py b/dfgraph/categories.py new file mode 100644 --- /dev/null +++ b/dfgraph/categories.py @@ -0,0 +1,74 @@ +"""Opcode-to-category mapping for visual graph rendering. + +Maps each ALUOp/MemOp/CfgOp to a visual category and colour +for the dataflow graph renderer. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Union + +from cm_inst import ArithOp, CfgOp, LogicOp, MemOp, RoutingOp + + +class OpcodeCategory(Enum): + ARITHMETIC = "arithmetic" + LOGIC = "logic" + COMPARISON = "comparison" + ROUTING = "routing" + MEMORY = "memory" + IO = "io" # reserved for future I/O ops (ior, iow, iorw) — not yet in asm/opcodes.py + CONFIG = "config" + + +CATEGORY_COLOURS: dict[OpcodeCategory, str] = { + OpcodeCategory.ARITHMETIC: "#4a90d9", + OpcodeCategory.LOGIC: "#4caf50", + OpcodeCategory.COMPARISON: "#ff9800", + OpcodeCategory.ROUTING: "#9c27b0", + OpcodeCategory.MEMORY: "#ff5722", + OpcodeCategory.IO: "#009688", + OpcodeCategory.CONFIG: "#9e9e9e", +} + + +_COMPARISON_OPS: frozenset[LogicOp] = frozenset({ + LogicOp.EQ, LogicOp.LT, LogicOp.LTE, LogicOp.GT, LogicOp.GTE, +}) + +_CONFIG_ROUTING_OPS: frozenset[RoutingOp] = frozenset({ + RoutingOp.CONST, RoutingOp.FREE_CTX, +}) + + +def categorise(op: Union[ArithOp, LogicOp, RoutingOp, MemOp, CfgOp]) -> OpcodeCategory: + """Categorise an opcode for visual rendering. + + Maps each opcode to a visual category used by the graph renderer. + Handles special cases like LogicOp comparison ops and RoutingOp config ops. + + Args: + op: An opcode enum value (ArithOp, LogicOp, RoutingOp, MemOp, or CfgOp) + + Returns: + The OpcodeCategory for this opcode + + Raises: + ValueError: If the opcode type is unknown + """ + if isinstance(op, ArithOp): + return OpcodeCategory.ARITHMETIC + if isinstance(op, LogicOp): + if op in _COMPARISON_OPS: + return OpcodeCategory.COMPARISON + return OpcodeCategory.LOGIC + if isinstance(op, RoutingOp): + if op in _CONFIG_ROUTING_OPS: + return OpcodeCategory.CONFIG + return OpcodeCategory.ROUTING + if isinstance(op, MemOp): + return OpcodeCategory.MEMORY + if isinstance(op, CfgOp): + return OpcodeCategory.CONFIG + raise ValueError(f"Unknown opcode type: {type(op).__name__}") diff --git a/tests/test_dfgraph_categories.py b/tests/test_dfgraph_categories.py new file mode 100644 --- /dev/null +++ b/tests/test_dfgraph_categories.py @@ -0,0 +1,176 @@ +"""Tests for dfgraph/categories.py — opcode-to-category mapping. + +Tests verify: +- Every opcode in MNEMONIC_TO_OP has a valid category via categorise() +- ArithOp members map to ARITHMETIC +- LogicOp logic ops map to LOGIC +- LogicOp comparison ops map to COMPARISON +- RoutingOp members (except CONST/FREE_CTX) map to ROUTING +- RoutingOp.CONST and FREE_CTX map to CONFIG +- MemOp members map to MEMORY +- CfgOp members map to CONFIG +- Every OpcodeCategory has a colour in CATEGORY_COLOURS +""" + +import pytest + +from cm_inst import ArithOp, CfgOp, LogicOp, MemOp, RoutingOp +from asm.opcodes import MNEMONIC_TO_OP +from dfgraph.categories import categorise, OpcodeCategory, CATEGORY_COLOURS + + +class TestCategoriseArithOp: + """Tests for ArithOp -> ARITHMETIC category mapping.""" + + @pytest.mark.parametrize("op", [ + ArithOp.ADD, + ArithOp.SUB, + ArithOp.INC, + ArithOp.DEC, + ArithOp.SHIFT_L, + ArithOp.SHIFT_R, + ArithOp.ASHFT_R, + ]) + def test_arith_ops_map_to_arithmetic(self, op): + """All ArithOp members map to ARITHMETIC category.""" + assert categorise(op) == OpcodeCategory.ARITHMETIC + + +class TestCategoriseLogicOp: + """Tests for LogicOp -> LOGIC or COMPARISON category mapping.""" + + @pytest.mark.parametrize("op", [ + LogicOp.AND, + LogicOp.OR, + LogicOp.XOR, + LogicOp.NOT, + ]) + def test_logic_ops_map_to_logic(self, op): + """Pure logic opcodes (AND, OR, XOR, NOT) map to LOGIC category.""" + assert categorise(op) == OpcodeCategory.LOGIC + + @pytest.mark.parametrize("op", [ + LogicOp.EQ, + LogicOp.LT, + LogicOp.LTE, + LogicOp.GT, + LogicOp.GTE, + ]) + def test_comparison_ops_map_to_comparison(self, op): + """Comparison opcodes (EQ, LT, LTE, GT, GTE) map to COMPARISON category.""" + assert categorise(op) == OpcodeCategory.COMPARISON + + +class TestCategoriseRoutingOp: + """Tests for RoutingOp -> ROUTING or CONFIG category mapping.""" + + @pytest.mark.parametrize("op", [ + RoutingOp.BREQ, + RoutingOp.BRGT, + RoutingOp.BRGE, + RoutingOp.BROF, + RoutingOp.SWEQ, + RoutingOp.SWGT, + RoutingOp.SWGE, + RoutingOp.SWOF, + RoutingOp.GATE, + RoutingOp.PASS, + RoutingOp.SEL, + RoutingOp.MRGE, + ]) + def test_routing_ops_map_to_routing(self, op): + """Routing/branch/switch/control opcodes map to ROUTING category.""" + assert categorise(op) == OpcodeCategory.ROUTING + + @pytest.mark.parametrize("op", [ + RoutingOp.CONST, + RoutingOp.FREE_CTX, + ]) + def test_config_routing_ops_map_to_config(self, op): + """RoutingOp.CONST and RoutingOp.FREE_CTX map to CONFIG category.""" + assert categorise(op) == OpcodeCategory.CONFIG + + +class TestCategoriseMemOp: + """Tests for MemOp -> MEMORY category mapping.""" + + @pytest.mark.parametrize("op", [ + MemOp.READ, + MemOp.WRITE, + MemOp.CLEAR, + MemOp.ALLOC, + MemOp.FREE, + MemOp.RD_INC, + MemOp.RD_DEC, + MemOp.CMP_SW, + ]) + def test_memory_ops_map_to_memory(self, op): + """All MemOp members map to MEMORY category.""" + assert categorise(op) == OpcodeCategory.MEMORY + + +class TestCategoriseCfgOp: + """Tests for CfgOp -> CONFIG category mapping.""" + + @pytest.mark.parametrize("op", [ + CfgOp.LOAD_INST, + CfgOp.ROUTE_SET, + ]) + def test_config_ops_map_to_config(self, op): + """All CfgOp members map to CONFIG category.""" + assert categorise(op) == OpcodeCategory.CONFIG + + +class TestCategoriseMnemonicToOp: + """Tests for all opcodes in MNEMONIC_TO_OP.""" + + @pytest.mark.parametrize("mnemonic, op", MNEMONIC_TO_OP.items()) + def test_all_mnemonics_have_category(self, mnemonic, op): + """Every opcode in MNEMONIC_TO_OP has a valid category via categorise().""" + # Should not raise ValueError + category = categorise(op) + assert isinstance(category, OpcodeCategory) + + +class TestCategoryColours: + """Tests for CATEGORY_COLOURS mapping.""" + + def test_all_categories_have_colour(self): + """Every OpcodeCategory has a colour in CATEGORY_COLOURS.""" + for category in OpcodeCategory: + assert category in CATEGORY_COLOURS + colour = CATEGORY_COLOURS[category] + assert isinstance(colour, str) + assert colour.startswith("#") + + def test_colours_are_valid_hex(self): + """All colours are valid 6-digit hex codes.""" + for category, colour in CATEGORY_COLOURS.items(): + assert len(colour) == 7, f"Colour for {category} is not 7 chars: {colour}" + assert colour[0] == "#", f"Colour for {category} doesn't start with #: {colour}" + try: + int(colour[1:], 16) + except ValueError: + pytest.fail(f"Colour for {category} is not valid hex: {colour}") + + def test_expected_colour_values(self): + """Verify the design-specified colours are present.""" + assert CATEGORY_COLOURS[OpcodeCategory.ARITHMETIC] == "#4a90d9" + assert CATEGORY_COLOURS[OpcodeCategory.LOGIC] == "#4caf50" + assert CATEGORY_COLOURS[OpcodeCategory.COMPARISON] == "#ff9800" + assert CATEGORY_COLOURS[OpcodeCategory.ROUTING] == "#9c27b0" + assert CATEGORY_COLOURS[OpcodeCategory.MEMORY] == "#ff5722" + assert CATEGORY_COLOURS[OpcodeCategory.IO] == "#009688" + assert CATEGORY_COLOURS[OpcodeCategory.CONFIG] == "#9e9e9e" + + +class TestCategoriseErrors: + """Tests for error handling in categorise().""" + + def test_unknown_type_raises_valueerror(self): + """Passing an unknown type to categorise() raises ValueError.""" + class UnknownOp: + pass + + with pytest.raises(ValueError, match="Unknown opcode type"): + categorise(UnknownOp()) # type: ignore -- tangled.sh