From 78e972762f6a64582cfd07d54ca92ec6390a3d1d Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 15:12:19 +0800 Subject: [PATCH 01/19] refactor(data): split step types into data/types.py and merge step_dirs into step Pure file reorganization with zero behavior change: - new data/types.py holds StepEnum, StateEnum, FINISHED_STEP_STATES, is_finished_step_state (enum vocabulary + pure predicates, no IO) - data/step.py keeps StepMetrics/load_metrics/save_metrics and absorbs STEP_DIRECTORIES from data/step_dirs.py (docstring included); data/step_dirs.py is deleted - data/__init__.py re-exports from .types; direct importers repointed --- chipcompiler/data/__init__.py | 12 ++- chipcompiler/data/step.py | 83 +++++++------------ chipcompiler/data/step_dirs.py | 31 ------- chipcompiler/data/types.py | 61 ++++++++++++++ chipcompiler/data/workspace/__init__.py | 2 +- chipcompiler/engine/reconcile.py | 6 +- chipcompiler/engine/signoff/collector.py | 2 +- chipcompiler/engine/signoff/report_extract.py | 2 +- chipcompiler/tools/ecc/signoff_checklist.py | 2 +- 9 files changed, 105 insertions(+), 96 deletions(-) delete mode 100644 chipcompiler/data/step_dirs.py create mode 100644 chipcompiler/data/types.py diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index 638b2e29..b07b574e 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -11,15 +11,18 @@ ) from .pdk import PDK, get_pdk from .step import ( - FINISHED_STEP_STATES, - StateEnum, - StepEnum, + STEP_DIRECTORIES, StepMetrics, - is_finished_step_state, load_metrics, save_metrics, step_storage_name, ) +from .types import ( + FINISHED_STEP_STATES, + StateEnum, + StepEnum, + is_finished_step_state, +) from .workspace import ( OriginDesign, Workspace, @@ -145,6 +148,7 @@ "StepMetrics", "load_metrics", "save_metrics", + "STEP_DIRECTORIES", "Checklist", "HomeData", ] diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index c532ed37..778d078a 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -1,51 +1,37 @@ #!/usr/bin/env python from dataclasses import dataclass, field -from enum import Enum from pathlib import Path - -class StepEnum(Enum): - """RTL2GDS flow step names""" - - RTL2GDS = "RTL2GDS" - INIT = "Init" - SYNTHESIS = "Synthesis" - FLOORPLAN = "Floorplan" # shared floorplan configuration key, not a flow step - PRE_FLOORPLAN = "preFloorplan" - MACRO_PLACEMENT = "macroPlacement" - POST_FLOORPLAN = "postFloorplan" - PLACEMENT = "place" - CTS = "CTS" - TIMING_OPT = "Timing optimization" - LEGALIZATION = "legalization" - ROUTING = "route" - FILLER = "filler" - GDS = "GDS" - SIGNOFF = "Signoff" - LEC = "lec" - POST_ROUTE_LEC = "postRouteLec" - STA = "sta" - DRC = "drc" - LVS = "lvs" - RCX = "RCX" - ABSTRACT_LEF = "Abstract lef" - HARDEN = "Harden" - - -class StateEnum(Enum): - """flow running state""" - - Invalid = "Invalid" # ecc tools or config invalid - Unstart = "Unstart" # step unstart - Success = "Success" # step run success - Ongoing = "Ongoing" # step is running - Pending = "Pending" # step is pending - Imcomplete = "Incomplete" # step is failed - # Ignored = "Ignored" # step result do not affect flow step - - -FINISHED_STEP_STATES = frozenset({StateEnum.Success.value}) +from chipcompiler.data.types import StepEnum + +# Canonical workspace step-directory names. +# +# Workspace creation names each step directory ``_`` along the +# canonical rtl2gds chain (``Synthesis_yosys``, ``place_dreamplace``, ...). +# Checklists, signoff packages, QoR scoring, and the design reports all +# resolve per-step artifacts through that naming, so the mapping lives here +# once instead of as hand-maintained tables per consumer. Timing +# optimization is on the canonical chain but owns no artifact directory any +# consumer reads through these tables. +STEP_DIRECTORIES = { + StepEnum.SYNTHESIS.value: "Synthesis_yosys", + StepEnum.LEC.value: "lec_yosys_lec", + StepEnum.PRE_FLOORPLAN.value: "preFloorplan_ecc", + StepEnum.MACRO_PLACEMENT.value: "macroPlacement_dreamplace", + StepEnum.POST_FLOORPLAN.value: "postFloorplan_ecc", + StepEnum.PLACEMENT.value: "place_dreamplace", + StepEnum.CTS.value: "CTS_ecc", + StepEnum.LEGALIZATION.value: "legalization_dreamplace", + StepEnum.ROUTING.value: "route_ecc", + StepEnum.FILLER.value: "filler_ecc", + StepEnum.RCX.value: "RCX_ecc", + StepEnum.STA.value: "sta_ecc", + StepEnum.LVS.value: "lvs_ecc", + StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", + StepEnum.DRC.value: "drc_ecc", + StepEnum.HARDEN.value: "Harden_ecc", +} def step_storage_name(step_name: str, tool_name: str) -> str: @@ -59,17 +45,6 @@ def step_storage_name(step_name: str, tool_name: str) -> str: return step_name -def is_finished_step_state(state: object) -> bool: - """Whether a persisted step state counts as done for selection and skipping. - - Incomplete/Invalid steps are unfinished: resume and rerun selectors - re-execute them. A legacy ``Warning`` state (removed terminal state for - the synthesis LEC) is not finished and is normalized to Unstart on - resume. - """ - return state in FINISHED_STEP_STATES - - ########################################################################### # step definition for chip design flow in json format # step_definition = diff --git a/chipcompiler/data/step_dirs.py b/chipcompiler/data/step_dirs.py deleted file mode 100644 index 2a91d1c8..00000000 --- a/chipcompiler/data/step_dirs.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Canonical workspace step-directory names. - -Workspace creation names each step directory ``_`` along the -canonical rtl2gds chain (``Synthesis_yosys``, ``place_dreamplace``, ...). -Checklists, signoff packages, QoR scoring, and the design reports all -resolve per-step artifacts through that naming, so the mapping lives here -once instead of as hand-maintained tables per consumer. Timing -optimization is on the canonical chain but owns no artifact directory any -consumer reads through these tables. -""" - -from chipcompiler.data.step import StepEnum - -STEP_DIRECTORIES = { - StepEnum.SYNTHESIS.value: "Synthesis_yosys", - StepEnum.LEC.value: "lec_yosys_lec", - StepEnum.PRE_FLOORPLAN.value: "preFloorplan_ecc", - StepEnum.MACRO_PLACEMENT.value: "macroPlacement_dreamplace", - StepEnum.POST_FLOORPLAN.value: "postFloorplan_ecc", - StepEnum.PLACEMENT.value: "place_dreamplace", - StepEnum.CTS.value: "CTS_ecc", - StepEnum.LEGALIZATION.value: "legalization_dreamplace", - StepEnum.ROUTING.value: "route_ecc", - StepEnum.FILLER.value: "filler_ecc", - StepEnum.RCX.value: "RCX_ecc", - StepEnum.STA.value: "sta_ecc", - StepEnum.LVS.value: "lvs_ecc", - StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", - StepEnum.DRC.value: "drc_ecc", - StepEnum.HARDEN.value: "Harden_ecc", -} diff --git a/chipcompiler/data/types.py b/chipcompiler/data/types.py new file mode 100644 index 00000000..9833f047 --- /dev/null +++ b/chipcompiler/data/types.py @@ -0,0 +1,61 @@ +"""Flow step and state vocabulary: enums plus enum-only pure predicates. + +Zero IO and zero chipcompiler-internal imports: this module is the +dependency-free root of the data layer's type vocabulary. +""" + +from enum import Enum + + +class StepEnum(Enum): + """RTL2GDS flow step names""" + + RTL2GDS = "RTL2GDS" + INIT = "Init" + SYNTHESIS = "Synthesis" + FLOORPLAN = "Floorplan" # shared floorplan configuration key, not a flow step + PRE_FLOORPLAN = "preFloorplan" + MACRO_PLACEMENT = "macroPlacement" + POST_FLOORPLAN = "postFloorplan" + PLACEMENT = "place" + CTS = "CTS" + TIMING_OPT = "Timing optimization" + LEGALIZATION = "legalization" + ROUTING = "route" + FILLER = "filler" + GDS = "GDS" + SIGNOFF = "Signoff" + LEC = "lec" + POST_ROUTE_LEC = "postRouteLec" + STA = "sta" + DRC = "drc" + LVS = "lvs" + RCX = "RCX" + ABSTRACT_LEF = "Abstract lef" + HARDEN = "Harden" + + +class StateEnum(Enum): + """flow running state""" + + Invalid = "Invalid" # ecc tools or config invalid + Unstart = "Unstart" # step unstart + Success = "Success" # step run success + Ongoing = "Ongoing" # step is running + Pending = "Pending" # step is pending + Imcomplete = "Incomplete" # step is failed + # Ignored = "Ignored" # step result do not affect flow step + + +FINISHED_STEP_STATES = frozenset({StateEnum.Success.value}) + + +def is_finished_step_state(state: object) -> bool: + """Whether a persisted step state counts as done for selection and skipping. + + Incomplete/Invalid steps are unfinished: resume and rerun selectors + re-execute them. A legacy ``Warning`` state (removed terminal state for + the synthesis LEC) is not finished and is normalized to Unstart on + resume. + """ + return state in FINISHED_STEP_STATES diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 20c50c4b..8e9779c1 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -22,7 +22,7 @@ load_parameter as load_parameter, ) from ..pdk import PDK, get_pdk -from ..step import StateEnum, StepEnum +from ..types import StateEnum, StepEnum from ..workspace_config import ( legacy_parameters_fallback as legacy_parameters_fallback, ) diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 1eb77e99..f0e29dec 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -231,7 +231,7 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): # every step WITHIN the requested target range finished; an # unfinished one resumes. Steps beyond the target are never the # run's business. - from chipcompiler.data.step import FINISHED_STEP_STATES + from chipcompiler.data.types import FINISHED_STEP_STATES target_states = { str(step.get("state", "")) @@ -247,7 +247,7 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): {}, ) - from chipcompiler.data.step import FINISHED_STEP_STATES + from chipcompiler.data.types import FINISHED_STEP_STATES states = { str(step.get("state", "")) for step in flow_data.get("steps", []) if isinstance(step, dict) @@ -406,7 +406,7 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) ) if outcome is None: - from chipcompiler.data.step import FINISHED_STEP_STATES + from chipcompiler.data.types import FINISHED_STEP_STATES if relation == "target_prefix": # The persisted flow already covers the target: no-op only diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index a34c7cd6..29897758 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -863,7 +863,7 @@ def _collect_debug_files( ) def _step_dirs(self) -> dict[str, str]: - from chipcompiler.data.step_dirs import STEP_DIRECTORIES + from chipcompiler.data.step import STEP_DIRECTORIES return STEP_DIRECTORIES diff --git a/chipcompiler/engine/signoff/report_extract.py b/chipcompiler/engine/signoff/report_extract.py index 63636767..2e43087b 100644 --- a/chipcompiler/engine/signoff/report_extract.py +++ b/chipcompiler/engine/signoff/report_extract.py @@ -5,7 +5,7 @@ from pathlib import Path from chipcompiler.data import StepEnum -from chipcompiler.data.step_dirs import STEP_DIRECTORIES as STEP_DIRS +from chipcompiler.data.step import STEP_DIRECTORIES as STEP_DIRS from chipcompiler.engine.signoff.report_data import ( DesignReportData, EvidenceProvenanceRecord, diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 5c9ab93a..4f113aae 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -9,7 +9,7 @@ from pathlib import Path from chipcompiler.data import Checklist, StateEnum, StepEnum, Workspace, WorkspaceStep -from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.data.step import STEP_DIRECTORIES from chipcompiler.tools.ecc.sta_qor import ( STA_QOR_SUMMARY_FILENAME, STA_REPORT_FILENAMES, From 6a005363a660d1c90aaf0a396688c91b27c0601c Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 15:18:29 +0800 Subject: [PATCH 02/19] feat(data): split skippable steps into SkippableStepEnum Three-layer step enum: memberless StepBaseEnum carries shared behavior; StepEnum keeps the core chain members; SkippableStepEnum owns the optional LEC, postRouteLec and Timing optimization steps (persisted values unchanged). step_from_value resolves a persisted step value across both enums; reverse lookups in tools/ecc service and subflow use it. All former StepEnum.LEC/POST_ROUTE_LEC/TIMING_OPT references are migrated to SkippableStepEnum --- chipcompiler/data/__init__.py | 6 +++ chipcompiler/data/step.py | 6 +-- chipcompiler/data/types.py | 52 +++++++++++++++++++-- chipcompiler/data/workspace/__init__.py | 28 +++++------ chipcompiler/engine/flow.py | 22 +++++---- chipcompiler/engine/signoff/collector.py | 10 ++-- chipcompiler/engine/signoff/discovery.py | 4 +- chipcompiler/rtl2gds/builder.py | 28 +++++------ chipcompiler/tools/ecc/builder.py | 3 +- chipcompiler/tools/ecc/runner.py | 3 +- chipcompiler/tools/ecc/service.py | 4 +- chipcompiler/tools/ecc/signoff_checklist.py | 25 +++++++--- chipcompiler/tools/ecc/subflow.py | 4 +- chipcompiler/tools/ecc_dreamplace/module.py | 4 +- test/data/test_types.py | 52 +++++++++++++++++++++ test/data/test_workspace.py | 8 ++-- test/formal/test_file_chaining.py | 10 ++-- test/rtl2gds/test_builder.py | 8 ++-- test/test_engine_flow.py | 21 +++++---- test/tools/ecc/test_module.py | 14 +++--- test/tools/ecc/test_runner.py | 7 +-- test/tools/ecc/test_signoff_checklist.py | 13 ++++-- test/tools/ecc_dreamplace/test_module.py | 30 +++++++----- test/tools/ecc_sizer/test_engine_flow.py | 20 ++++---- test/tools/ecc_sizer/test_module.py | 38 +++++++-------- test/tools/ecc_sizer/test_runner.py | 18 +++---- test/tools/ecc_sizer/test_runner_cleanup.py | 18 +++---- test/yosys_lec/test_tools_yosys_lec.py | 43 +++++++++-------- 28 files changed, 322 insertions(+), 177 deletions(-) create mode 100644 test/data/test_types.py diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index b07b574e..28727263 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -19,9 +19,12 @@ ) from .types import ( FINISHED_STEP_STATES, + SkippableStepEnum, StateEnum, + StepBaseEnum, StepEnum, is_finished_step_state, + step_from_value, ) from .workspace import ( OriginDesign, @@ -143,7 +146,10 @@ "get_pdk", "StepEnum", "step_storage_name", + "SkippableStepEnum", + "StepBaseEnum", "StateEnum", + "step_from_value", "CheckState", "StepMetrics", "load_metrics", diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py index 778d078a..adf14dc8 100644 --- a/chipcompiler/data/step.py +++ b/chipcompiler/data/step.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from pathlib import Path -from chipcompiler.data.types import StepEnum +from chipcompiler.data.types import SkippableStepEnum, StepEnum # Canonical workspace step-directory names. # @@ -16,7 +16,7 @@ # consumer reads through these tables. STEP_DIRECTORIES = { StepEnum.SYNTHESIS.value: "Synthesis_yosys", - StepEnum.LEC.value: "lec_yosys_lec", + SkippableStepEnum.LEC.value: "lec_yosys_lec", StepEnum.PRE_FLOORPLAN.value: "preFloorplan_ecc", StepEnum.MACRO_PLACEMENT.value: "macroPlacement_dreamplace", StepEnum.POST_FLOORPLAN.value: "postFloorplan_ecc", @@ -28,7 +28,7 @@ StepEnum.RCX.value: "RCX_ecc", StepEnum.STA.value: "sta_ecc", StepEnum.LVS.value: "lvs_ecc", - StepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", + SkippableStepEnum.POST_ROUTE_LEC.value: "postRouteLec_yosys_lec", StepEnum.DRC.value: "drc_ecc", StepEnum.HARDEN.value: "Harden_ecc", } diff --git a/chipcompiler/data/types.py b/chipcompiler/data/types.py index 9833f047..5cc13742 100644 --- a/chipcompiler/data/types.py +++ b/chipcompiler/data/types.py @@ -7,8 +7,21 @@ from enum import Enum -class StepEnum(Enum): - """RTL2GDS flow step names""" +class StepBaseEnum(Enum): + """Memberless base of the flow step enums: shared behavior. + + Python forbids inheriting an Enum that has members, so shared step + behavior lives on this base while the concrete members are split + between :class:`StepEnum` (core chain steps) and + :class:`SkippableStepEnum` (optional steps a project may exclude). + """ + + def is_skippable(self) -> bool: + return False + + +class StepEnum(StepBaseEnum): + """RTL2GDS flow step names (core chain steps)""" RTL2GDS = "RTL2GDS" INIT = "Init" @@ -19,14 +32,11 @@ class StepEnum(Enum): POST_FLOORPLAN = "postFloorplan" PLACEMENT = "place" CTS = "CTS" - TIMING_OPT = "Timing optimization" LEGALIZATION = "legalization" ROUTING = "route" FILLER = "filler" GDS = "GDS" SIGNOFF = "Signoff" - LEC = "lec" - POST_ROUTE_LEC = "postRouteLec" STA = "sta" DRC = "drc" LVS = "lvs" @@ -35,6 +45,38 @@ class StepEnum(Enum): HARDEN = "Harden" +class SkippableStepEnum(StepBaseEnum): + """Optional flow steps a project may exclude from its ledger. + + These are check/optimization steps whose outputs downstream steps can + do without; persisted string values match the former StepEnum members. + """ + + LEC = "lec" + POST_ROUTE_LEC = "postRouteLec" + TIMING_OPT = "Timing optimization" + + def is_skippable(self) -> bool: + return True + + +_STEP_ENUMS: tuple[type[StepBaseEnum], ...] = (StepEnum, SkippableStepEnum) + + +def step_from_value(name: str) -> StepBaseEnum: + """The step enum member for a persisted step value, across both enums. + + Raises ValueError for an unknown value, mirroring ``StepEnum(name)``. + """ + for enum in _STEP_ENUMS: + try: + return enum(name) + except ValueError: + continue + legal = ", ".join(sorted(member.value for enum in _STEP_ENUMS for member in enum)) + raise ValueError(f"unknown flow step: {name!r}; available steps: {legal}") + + class StateEnum(Enum): """flow running state""" diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 8e9779c1..5b9acdae 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -22,7 +22,7 @@ load_parameter as load_parameter, ) from ..pdk import PDK, get_pdk -from ..types import StateEnum, StepEnum +from ..types import SkippableStepEnum, StateEnum, StepBaseEnum, StepEnum from ..workspace_config import ( legacy_parameters_fallback as legacy_parameters_fallback, ) @@ -79,8 +79,8 @@ def steps(self) -> list[dict]: return [] return [step for step in raw_steps if isinstance(step, dict)] - def get_step(self, name: str | StepEnum, tool: str | None = None) -> dict | None: - step_name = name.value if isinstance(name, StepEnum) else name + def get_step(self, name: str | StepBaseEnum, tool: str | None = None) -> dict | None: + step_name = name.value if isinstance(name, StepBaseEnum) else name for step in self.steps(): if step.get("name") != step_name: continue @@ -88,7 +88,7 @@ def get_step(self, name: str | StepEnum, tool: str | None = None) -> dict | None return step return None - def has_step(self, name: str | StepEnum, tool: str | None = None) -> bool: + def has_step(self, name: str | StepBaseEnum, tool: str | None = None) -> bool: return self.get_step(name, tool) is not None @@ -187,9 +187,11 @@ def log_workspace_step(step: WorkspaceStep, logger: Logger): "dreamplace": "dreamplace.json", } -_STEP_BY_VALUE: Final[dict[str, StepEnum]] = {step.value: step for step in StepEnum} +_STEP_BY_VALUE: Final[dict[str, StepBaseEnum]] = { + step.value: step for step in (*StepEnum, *SkippableStepEnum) +} -_STEP_CONFIG_KEYS: Final[dict[tuple[StepEnum, str], tuple[str, ...]]] = { +_STEP_CONFIG_KEYS: Final[dict[tuple[StepBaseEnum, str], tuple[str, ...]]] = { (StepEnum.PRE_FLOORPLAN, "ecc"): ("db", StepEnum.FLOORPLAN.value), (StepEnum.MACRO_PLACEMENT, "dreamplace"): ("dreamplace", "macro_location"), (StepEnum.POST_FLOORPLAN, "ecc"): ("db", StepEnum.FLOORPLAN.value, "macro_location"), @@ -203,12 +205,12 @@ def log_workspace_step(step: WorkspaceStep, logger: Logger): (StepEnum.STA, "ecc"): ("db", StepEnum.RCX.value, StepEnum.STA.value), (StepEnum.PLACEMENT, "dreamplace"): ("dreamplace",), (StepEnum.LEGALIZATION, "dreamplace"): ("dreamplace",), - (StepEnum.TIMING_OPT, "sizer"): ("db", "dreamplace"), + (SkippableStepEnum.TIMING_OPT, "sizer"): ("db", "dreamplace"), } -def _workspace_step_enum(step: str | StepEnum) -> StepEnum | None: - if isinstance(step, StepEnum): +def _workspace_step_enum(step: str | StepBaseEnum) -> StepBaseEnum | None: + if isinstance(step, StepBaseEnum): return step return _STEP_BY_VALUE.get(step) @@ -246,7 +248,7 @@ def workspace_config_path(workspace_dir: str | Path, config_key: str) -> Path | return workspace_config_paths(workspace_dir).get(config_key) -def step_config_keys(step: str | StepEnum, tool: str | None) -> tuple[str, ...]: +def step_config_keys(step: str | StepBaseEnum, tool: str | None) -> tuple[str, ...]: step_enum = _workspace_step_enum(step) if step_enum is None or tool is None: return () @@ -255,7 +257,7 @@ def step_config_keys(step: str | StepEnum, tool: str | None) -> tuple[str, ...]: def step_config_paths( workspace_dir: str | Path, - step: str | StepEnum, + step: str | StepBaseEnum, tool: str | None, *, existing_only: bool = False, @@ -301,7 +303,7 @@ def build_dynamic_flow_data(flow_config: dict | None) -> dict: return { "steps": [ _flow_step_template( - name.value if isinstance(name, StepEnum) else str(name), + name.value if isinstance(name, StepBaseEnum) else str(name), str(tool), state.value if isinstance(state, StateEnum) else str(state), ) @@ -315,7 +317,7 @@ def _canonical_rtl2gds_flow_entries() -> list[tuple[str, str, str]]: return [ ( - step.value if isinstance(step, StepEnum) else str(step), + step.value if isinstance(step, StepBaseEnum) else str(step), str(tool), state.value if isinstance(state, StateEnum) else str(state), ) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index faf74896..7575e3bd 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -8,7 +8,9 @@ from chipcompiler.data import ( EccOutput, + SkippableStepEnum, StateEnum, + StepBaseEnum, StepEnum, Workspace, WorkspaceStep, @@ -97,7 +99,7 @@ def _validate_transition(old_state: str | None, new_state: str, step_name: str, StepEnum.POST_FLOORPLAN.value, StepEnum.PLACEMENT.value, StepEnum.CTS.value, - StepEnum.TIMING_OPT.value, + SkippableStepEnum.TIMING_OPT.value, StepEnum.LEGALIZATION.value, StepEnum.ROUTING.value, StepEnum.DRC.value, @@ -126,7 +128,9 @@ def build_default_steps(self): golden = getattr(self.workspace.design, "golden_verilog", None) lec_info = {"golden_verilog": str(golden)} if golden else None steps.append( - self.init_flow_step(StepEnum.LEC, "yosys_lec", StateEnum.Unstart, info=lec_info) + self.init_flow_step( + SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart, info=lec_info + ) ) steps.append(self.init_flow_step(StepEnum.PRE_FLOORPLAN, "ecc", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.MACRO_PLACEMENT, "dreamplace", StateEnum.Unstart)) @@ -134,7 +138,7 @@ def build_default_steps(self): steps.append(self.init_flow_step(StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.CTS, "ecc", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.LEGALIZATION, "dreamplace", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.TIMING_OPT, "sizer", StateEnum.Unstart)) + steps.append(self.init_flow_step(SkippableStepEnum.TIMING_OPT, "sizer", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.ROUTING, "ecc", StateEnum.Unstart)) steps.append(self.init_flow_step(StepEnum.FILLER, "ecc", StateEnum.Unstart)) # steps.append(self.init_flow_step(StepEnum.GDS, "klayout", StateEnum.Unstart)) @@ -149,12 +153,12 @@ def has_init(self): def init_flow_step( self, - step: StepEnum | str, + step: StepBaseEnum | str, tool: str, state: str | StateEnum, info: dict | None = None, ): - step_value = step.value if isinstance(step, StepEnum) else step + step_value = step.value if isinstance(step, StepBaseEnum) else step state_value = state.value if isinstance(state, StateEnum) else state return { "name": step_value, # step name @@ -293,8 +297,8 @@ def check_step_result(self, workspace_step: WorkspaceStep): # HARDEN/RCX/GDS results live on the place-and-route (ecc) output leaves. ecc_output = output if isinstance(output, EccOutput) else None if workspace_step.tool == "yosys_lec" or workspace_step.name in ( - StepEnum.LEC.value, - StepEnum.POST_ROUTE_LEC.value, + SkippableStepEnum.LEC.value, + SkippableStepEnum.POST_ROUTE_LEC.value, ): from chipcompiler.tools.yosys_lec.utility import lec_result_is_proven @@ -330,7 +334,7 @@ def check_step_result(self, workspace_step: WorkspaceStep): success = bool(spef_list) and all( os.path.isfile(spef) and os.path.getsize(spef) > 0 for spef in spef_list ) - case StepEnum.TIMING_OPT.value: + case SkippableStepEnum.TIMING_OPT.value: if os.path.exists(output.def_ or "") and os.path.exists(output.verilog or ""): success = True case _: @@ -391,7 +395,7 @@ def create_step_workspaces(self, *, executable_steps: set[str] | None = None): input_db = explicit_golden elif pre_step is None and self.workspace.design.golden_verilog is not None: input_db = self.workspace.design.golden_verilog - elif step["name"] == StepEnum.POST_ROUTE_LEC.value: + elif step["name"] == SkippableStepEnum.POST_ROUTE_LEC.value: input_db = synthesis_gate_verilog or self.workspace.design.origin_verilog elif pre_step is not None and pre_step.name == StepEnum.SYNTHESIS.value: input_db = synthesis_golden_verilog or None diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index 29897758..8bae7c56 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -15,7 +15,7 @@ import time from pathlib import Path -from chipcompiler.data import StateEnum, StepEnum, Workspace +from chipcompiler.data import SkippableStepEnum, StateEnum, StepEnum, Workspace from chipcompiler.engine.signoff.analysis import CollectorAnalysisMixin from chipcompiler.engine.signoff.discovery import CollectorDiscoveryMixin from chipcompiler.engine.signoff.models import ( @@ -366,8 +366,10 @@ def add_file( required=True, ) - lec_dir = workspace_dir / self._step_dirs()[StepEnum.POST_ROUTE_LEC.value] - lec_result = lec_dir / "output" / f"{design}_{StepEnum.POST_ROUTE_LEC.value}_result.json" + lec_dir = workspace_dir / self._step_dirs()[SkippableStepEnum.POST_ROUTE_LEC.value] + lec_result = ( + lec_dir / "output" / f"{design}_{SkippableStepEnum.POST_ROUTE_LEC.value}_result.json" + ) if require_lec: add_file( role="lec.result", @@ -545,7 +547,7 @@ def add_file( add_file("status.flow", flow_path, "final/reports/flow.json", required=True) for step_name, step_dir in self._step_dirs().items(): - if step_name == StepEnum.POST_ROUTE_LEC.value: + if step_name == SkippableStepEnum.POST_ROUTE_LEC.value: continue for kind in ("analysis", "report"): self._copy_tree_files( diff --git a/chipcompiler/engine/signoff/discovery.py b/chipcompiler/engine/signoff/discovery.py index 67343304..d1f9395e 100644 --- a/chipcompiler/engine/signoff/discovery.py +++ b/chipcompiler/engine/signoff/discovery.py @@ -9,7 +9,7 @@ import json from pathlib import Path -from chipcompiler.data import StepEnum +from chipcompiler.data import SkippableStepEnum, StepEnum class CollectorDiscoveryMixin: @@ -109,7 +109,7 @@ def _required_step_states(self, *, require_lec: bool) -> dict: StepEnum.ROUTING.value, ] if require_lec: - required.append(StepEnum.POST_ROUTE_LEC.value) + required.append(SkippableStepEnum.POST_ROUTE_LEC.value) states = {} for step in required: entry = self.workspace.flow.get_step(step) diff --git a/chipcompiler/rtl2gds/builder.py b/chipcompiler/rtl2gds/builder.py index 79f89d7c..56c9dec8 100644 --- a/chipcompiler/rtl2gds/builder.py +++ b/chipcompiler/rtl2gds/builder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python from collections.abc import Callable -from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data import SkippableStepEnum, StateEnum, StepBaseEnum, StepEnum def build_rtl2gds_flow() -> list: @@ -9,29 +9,29 @@ def build_rtl2gds_flow() -> list: steps.append((StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart)) # LEC is still unstable; keep it disabled until it is reliable enough to enable. - # steps.append((StepEnum.LEC, "yosys_lec", StateEnum.Unstart)) + # steps.append((SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart)) steps.append((StepEnum.PRE_FLOORPLAN, "ecc", StateEnum.Unstart)) steps.append((StepEnum.MACRO_PLACEMENT, "dreamplace", StateEnum.Unstart)) steps.append((StepEnum.POST_FLOORPLAN, "ecc", StateEnum.Unstart)) steps.append((StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart)) steps.append((StepEnum.CTS, "ecc", StateEnum.Unstart)) steps.append((StepEnum.LEGALIZATION, "dreamplace", StateEnum.Unstart)) - steps.append((StepEnum.TIMING_OPT, "sizer", StateEnum.Unstart)) + steps.append((SkippableStepEnum.TIMING_OPT, "sizer", StateEnum.Unstart)) steps.append((StepEnum.ROUTING, "ecc", StateEnum.Unstart)) steps.append((StepEnum.FILLER, "ecc", StateEnum.Unstart)) steps.append((StepEnum.RCX, "ecc", StateEnum.Unstart)) steps.append((StepEnum.STA, "ecc", StateEnum.Unstart)) steps.append((StepEnum.LVS, "ecc", StateEnum.Unstart)) - steps.append((StepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart)) + steps.append((SkippableStepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart)) steps.append((StepEnum.DRC, "ecc", StateEnum.Unstart)) steps.append((StepEnum.HARDEN, "ecc", StateEnum.Unstart)) return steps -def normalize_flow_step(value: str | StepEnum) -> str: +def normalize_flow_step(value: str | StepBaseEnum) -> str: """Resolve a CLI/manifest step spelling to its canonical flow name.""" - if isinstance(value, StepEnum): + if isinstance(value, StepBaseEnum): return value.value token = str(value or "").strip() if not token: @@ -52,16 +52,16 @@ def normalize_flow_step(value: str | StepEnum) -> str: "cts": StepEnum.CTS.value, "legal": StepEnum.LEGALIZATION.value, "legalization": StepEnum.LEGALIZATION.value, - "timingopt": StepEnum.TIMING_OPT.value, - "timingoptimization": StepEnum.TIMING_OPT.value, + "timingopt": SkippableStepEnum.TIMING_OPT.value, + "timingoptimization": SkippableStepEnum.TIMING_OPT.value, "route": StepEnum.ROUTING.value, "routing": StepEnum.ROUTING.value, "drc": StepEnum.DRC.value, "lvs": StepEnum.LVS.value, "filler": StepEnum.FILLER.value, - "lec": StepEnum.LEC.value, - "postlec": StepEnum.POST_ROUTE_LEC.value, - "postroutelec": StepEnum.POST_ROUTE_LEC.value, + "lec": SkippableStepEnum.LEC.value, + "postlec": SkippableStepEnum.POST_ROUTE_LEC.value, + "postroutelec": SkippableStepEnum.POST_ROUTE_LEC.value, "rcx": StepEnum.RCX.value, "sta": StepEnum.STA.value, "harden": StepEnum.HARDEN.value, @@ -69,7 +69,7 @@ def normalize_flow_step(value: str | StepEnum) -> str: return aliases.get(alias_key, token) -def build_flow_range(from_step: str | StepEnum, to_step: str | StepEnum) -> list: +def build_flow_range(from_step: str | StepBaseEnum, to_step: str | StepBaseEnum) -> list: """Return the inclusive canonical RTL-to-GDS range requested by a workspace. The RTL-to-GDS chain is owned by :func:`build_rtl2gds_flow`; partial flows @@ -77,7 +77,7 @@ def build_flow_range(from_step: str | StepEnum, to_step: str | StepEnum) -> list """ steps = build_rtl2gds_flow() names = [ - step.value if isinstance(step, StepEnum) else str(step) for step, _tool, _state in steps + step.value if isinstance(step, StepBaseEnum) else str(step) for step, _tool, _state in steps ] first = normalize_flow_step(from_step) last = normalize_flow_step(to_step) @@ -105,7 +105,7 @@ def build_synthesis_lec_flow() -> list: steps = [] steps.append((StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart)) - steps.append((StepEnum.LEC, "yosys_lec", StateEnum.Unstart)) + steps.append((SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart)) return steps diff --git a/chipcompiler/tools/ecc/builder.py b/chipcompiler/tools/ecc/builder.py index 2b756bf0..c16f3662 100644 --- a/chipcompiler/tools/ecc/builder.py +++ b/chipcompiler/tools/ecc/builder.py @@ -11,6 +11,7 @@ EccScript, EccStep, LogPaths, + SkippableStepEnum, StepEnum, StepInput, SubflowState, @@ -93,7 +94,7 @@ def build_step( StepEnum.LEGALIZATION.value: data_dir / "pl", StepEnum.FILLER.value: data_dir / "pl", StepEnum.CTS.value: data_dir / "cts", - StepEnum.TIMING_OPT.value: data_dir / "to", + SkippableStepEnum.TIMING_OPT.value: data_dir / "to", StepEnum.ROUTING.value: data_dir / "rt", StepEnum.STA.value: sta_dir, StepEnum.DRC.value: data_dir / "drc", diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 5fbda7f5..19cf2d15 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -5,6 +5,7 @@ from chipcompiler.data import ( EccStep, + SkippableStepEnum, StateEnum, StepEnum, Workspace, @@ -40,7 +41,7 @@ StepEnum.POST_FLOORPLAN.value, StepEnum.PLACEMENT.value, StepEnum.CTS.value, - StepEnum.TIMING_OPT.value, + SkippableStepEnum.TIMING_OPT.value, StepEnum.LEGALIZATION.value, StepEnum.ROUTING.value, StepEnum.DRC.value, diff --git a/chipcompiler/tools/ecc/service.py b/chipcompiler/tools/ecc/service.py index 988e74ea..4abc815a 100644 --- a/chipcompiler/tools/ecc/service.py +++ b/chipcompiler/tools/ecc/service.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from chipcompiler.data import EccStep, StepEnum, Workspace +from chipcompiler.data import EccStep, StepEnum, Workspace, step_from_value from chipcompiler.tools.ecc.metrics import build_step_metrics from chipcompiler.utility import dict_to_str, json_read from chipcompiler.utility.path import stringify_paths @@ -103,7 +103,7 @@ def build_analysis(workspace: Workspace, step: EccStep) -> dict: def build_maps(workspace: Workspace, step: EccStep) -> dict: info = {} - match StepEnum(step.name): + match step_from_value(step.name): case StepEnum.PRE_FLOORPLAN | StepEnum.POST_FLOORPLAN: pass case StepEnum.PLACEMENT: diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 4f113aae..2f22fae3 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -8,7 +8,14 @@ import re from pathlib import Path -from chipcompiler.data import Checklist, StateEnum, StepEnum, Workspace, WorkspaceStep +from chipcompiler.data import ( + Checklist, + SkippableStepEnum, + StateEnum, + StepEnum, + Workspace, + WorkspaceStep, +) from chipcompiler.data.step import STEP_DIRECTORIES from chipcompiler.tools.ecc.sta_qor import ( STA_QOR_SUMMARY_FILENAME, @@ -43,7 +50,7 @@ StepEnum.DRC.value, StepEnum.LVS.value, StepEnum.FILLER.value, - StepEnum.POST_ROUTE_LEC.value, + SkippableStepEnum.POST_ROUTE_LEC.value, StepEnum.RCX.value, StepEnum.STA.value, StepEnum.HARDEN.value, @@ -409,7 +416,7 @@ def missing_paths(paths, validator=None): ] elif step.name == StepEnum.SYNTHESIS.value: artifacts = (("netlist", "Mapped synthesis netlist", step.output.verilog),) - elif step.name in {StepEnum.LEC.value, StepEnum.POST_ROUTE_LEC.value}: + elif step.name in {SkippableStepEnum.LEC.value, SkippableStepEnum.POST_ROUTE_LEC.value}: step_input = getattr(step, "input", None) return _lec_artifact_items( workspace, @@ -531,7 +538,9 @@ def _flow_items(workspace: Workspace) -> list[dict]: } items = [] for step in _REQUIRED_FLOW_STEPS: - if step == StepEnum.POST_ROUTE_LEC.value and not _requires_post_route_lec(workspace): + if step == SkippableStepEnum.POST_ROUTE_LEC.value and not _requires_post_route_lec( + workspace + ): continue state = "pass" if states.get(step) == StateEnum.Success.value else "failed" items.append( @@ -648,7 +657,7 @@ def rebuild_home_checklist( return {} workspace_dir = Path(workspace_directory) items = [] - post_route_lec_dir = STEP_DIRECTORIES[StepEnum.POST_ROUTE_LEC.value] + post_route_lec_dir = STEP_DIRECTORIES[SkippableStepEnum.POST_ROUTE_LEC.value] for directory in STEP_DIRECTORIES.values(): if directory == post_route_lec_dir: continue @@ -662,10 +671,12 @@ def rebuild_home_checklist( workspace_dir / post_route_lec_dir / "output" - / f"{design}_{StepEnum.POST_ROUTE_LEC.value}_result.json" + / f"{design}_{SkippableStepEnum.POST_ROUTE_LEC.value}_result.json" ) items.extend( - _lec_artifact_items(workspace, StepEnum.POST_ROUTE_LEC.value, result_json, golden, gate) + _lec_artifact_items( + workspace, SkippableStepEnum.POST_ROUTE_LEC.value, result_json, golden, gate + ) ) for step_name in _QUALITY_GATES_BY_STEP: step_directory = workspace_dir / STEP_DIRECTORIES[step_name] diff --git a/chipcompiler/tools/ecc/subflow.py b/chipcompiler/tools/ecc/subflow.py index 90af7c3b..356cf5b6 100644 --- a/chipcompiler/tools/ecc/subflow.py +++ b/chipcompiler/tools/ecc/subflow.py @@ -2,7 +2,7 @@ import time from enum import Enum -from chipcompiler.data import StateEnum, StepEnum, Workspace, WorkspaceStep +from chipcompiler.data import StateEnum, StepEnum, Workspace, WorkspaceStep, step_from_value class EccSubFlowEnum(Enum): @@ -65,7 +65,7 @@ def subflow_template(step_name: str): steps = [] - step = StepEnum(self.workspace_step.name) + step = step_from_value(self.workspace_step.name) match step: case StepEnum.PRE_FLOORPLAN: steps.append(subflow_template(EccSubFlowEnum.load_data.value)) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index bb6d2372..8c54bf59 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -8,14 +8,14 @@ from enum import Enum from pathlib import Path -from chipcompiler.data import StepEnum, Workspace, WorkspaceStep +from chipcompiler.data import SkippableStepEnum, StepEnum, Workspace, WorkspaceStep from chipcompiler.tools.ecc.module import ECCToolsModule from chipcompiler.utility.path import optional_path, path_text _LEGALIZE_OWNERS = frozenset( { StepEnum.LEGALIZATION.value, - StepEnum.TIMING_OPT.value, + SkippableStepEnum.TIMING_OPT.value, } ) diff --git a/test/data/test_types.py b/test/data/test_types.py new file mode 100644 index 00000000..ade5bc2b --- /dev/null +++ b/test/data/test_types.py @@ -0,0 +1,52 @@ +import pytest + +from chipcompiler.data.types import ( + SkippableStepEnum, + StepBaseEnum, + StepEnum, + step_from_value, +) + + +def test_step_base_enum_defines_no_members_and_defaults_to_not_skippable(): + assert len(list(StepBaseEnum)) == 0 + assert StepEnum.SYNTHESIS.is_skippable() is False + + +def test_skippable_step_enum_members_are_marked_skippable(): + assert {member.name for member in SkippableStepEnum} == { + "LEC", + "POST_ROUTE_LEC", + "TIMING_OPT", + } + assert all(member.is_skippable() is True for member in SkippableStepEnum) + assert {member.value for member in SkippableStepEnum} == { + "lec", + "postRouteLec", + "Timing optimization", + } + + +def test_core_enum_no_longer_carries_the_skippable_members(): + for name in ("LEC", "POST_ROUTE_LEC", "TIMING_OPT"): + assert not hasattr(StepEnum, name) + with pytest.raises(ValueError): + StepEnum(getattr(SkippableStepEnum, name).value) + + +def test_both_enums_share_the_memberless_base(): + assert issubclass(StepEnum, StepBaseEnum) + assert issubclass(SkippableStepEnum, StepBaseEnum) + + +@pytest.mark.parametrize( + "value,expected", + [(member.value, member) for member in (*StepEnum, *SkippableStepEnum)], +) +def test_step_from_value_resolves_both_enums(value, expected): + assert step_from_value(value) is expected + + +def test_step_from_value_rejects_unknown_values(): + with pytest.raises(ValueError, match="unknown flow step: 'bogus'"): + step_from_value("bogus") diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 57e795f3..3fd3b22f 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -7,6 +7,8 @@ import chipcompiler.data as data_api import chipcompiler.data.workspace as workspace_data from chipcompiler.data import ( + SkippableStepEnum, + StepBaseEnum, StepEnum, create_workspace, load_workspace, @@ -683,7 +685,7 @@ def test_step_config_keys_return_workspace_config_keys(): assert data_api.step_config_keys("place", "dreamplace") == ("dreamplace",) assert data_api.step_config_keys("legalization", "dreamplace") == ("dreamplace",) assert data_api.step_config_keys("Timing optimization", "sizer") == ("db", "dreamplace") - assert data_api.step_config_keys(StepEnum.TIMING_OPT, "sizer") == ("db", "dreamplace") + assert data_api.step_config_keys(SkippableStepEnum.TIMING_OPT, "sizer") == ("db", "dreamplace") assert data_api.step_config_keys("synthesis", "yosys") == () assert data_api.step_config_keys("place", None) == () @@ -733,7 +735,7 @@ def test_step_config_paths_return_expected_and_existing_paths(tmp_path): assert data_api.step_config_paths(workspace_dir, "legalization", "dreamplace") == ( config_dir / "dreamplace_ecc.json", ) - assert data_api.step_config_paths(workspace_dir, StepEnum.TIMING_OPT, "sizer") == ( + assert data_api.step_config_paths(workspace_dir, SkippableStepEnum.TIMING_OPT, "sizer") == ( config_dir / "db_ecc.json", config_dir / "dreamplace_ecc.json", ) @@ -759,7 +761,7 @@ def test_workspace_config_metadata_is_private_and_step_enum_keyed(): assert hasattr(workspace_data, "_WORKSPACE_CONFIG_FILENAMES") assert hasattr(workspace_data, "_STEP_CONFIG_KEYS") assert all( - isinstance(step, StepEnum) and isinstance(tool, str) + isinstance(step, StepBaseEnum) and isinstance(tool, str) for step, tool in workspace_data._STEP_CONFIG_KEYS ) diff --git a/test/formal/test_file_chaining.py b/test/formal/test_file_chaining.py index 13d0cd2a..3b962177 100644 --- a/test/formal/test_file_chaining.py +++ b/test/formal/test_file_chaining.py @@ -20,17 +20,19 @@ unsat, ) -from chipcompiler.data import StepEnum +from chipcompiler.data import SkippableStepEnum, StepBaseEnum, StepEnum -# Assign each StepEnum member an integer ID for z3. -STEP_TYPE_MAP: dict[StepEnum, int] = {member: i for i, member in enumerate(StepEnum)} +# Assign each concrete step enum member an integer ID for z3. +STEP_TYPE_MAP: dict[StepBaseEnum, int] = { + member: i for i, member in enumerate((*StepEnum, *SkippableStepEnum)) +} STEP_TYPE_COUNT: int = len(STEP_TYPE_MAP) # Steps that only require a mapped netlist (not def/gds). SYNTHESIS_ONLY_STEPS: set[StepEnum] = {StepEnum.SYNTHESIS} # Steps whose success contract is a proven result JSON, not physical outputs. -JSON_ONLY_STEPS: set[StepEnum] = {StepEnum.LEC, StepEnum.POST_ROUTE_LEC} +JSON_ONLY_STEPS: set[StepBaseEnum] = {SkippableStepEnum.LEC, SkippableStepEnum.POST_ROUTE_LEC} def _expected_output_keys( diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index 8ee6d755..4fdf1b23 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -1,7 +1,7 @@ import pytest import chipcompiler.rtl2gds.builder as builder_module -from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data import SkippableStepEnum, StateEnum, StepEnum from chipcompiler.rtl2gds import get_flow_builders @@ -62,13 +62,13 @@ def test_build_rtl2gds_flow_is_the_complete_flow(): (StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart), (StepEnum.CTS, "ecc", StateEnum.Unstart), (StepEnum.LEGALIZATION, "dreamplace", StateEnum.Unstart), - (StepEnum.TIMING_OPT, "sizer", StateEnum.Unstart), + (SkippableStepEnum.TIMING_OPT, "sizer", StateEnum.Unstart), (StepEnum.ROUTING, "ecc", StateEnum.Unstart), (StepEnum.FILLER, "ecc", StateEnum.Unstart), (StepEnum.RCX, "ecc", StateEnum.Unstart), (StepEnum.STA, "ecc", StateEnum.Unstart), (StepEnum.LVS, "ecc", StateEnum.Unstart), - (StepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart), + (SkippableStepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart), (StepEnum.DRC, "ecc", StateEnum.Unstart), (StepEnum.HARDEN, "ecc", StateEnum.Unstart), ] @@ -80,7 +80,7 @@ def test_build_flow_range_slices_the_canonical_chain(): assert [(step, tool) for step, tool, _state in flow] == [ (StepEnum.CTS, "ecc"), (StepEnum.LEGALIZATION, "dreamplace"), - (StepEnum.TIMING_OPT, "sizer"), + (SkippableStepEnum.TIMING_OPT, "sizer"), (StepEnum.ROUTING, "ecc"), ] diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 068dc017..0fb6bacf 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -13,6 +13,7 @@ EccOutput, EccStep, LogPaths, + SkippableStepEnum, StateEnum, StepEnum, StepMetrics, @@ -69,7 +70,7 @@ def test_engine_flow_default_steps_include_synthesis_lec(tmp_path): assert [(step["name"], step["tool"]) for step in workspace.flow.data["steps"][:5]] == [ (StepEnum.SYNTHESIS.value, "yosys"), - (StepEnum.LEC.value, "yosys_lec"), + (SkippableStepEnum.LEC.value, "yosys_lec"), (StepEnum.PRE_FLOORPLAN.value, "ecc"), (StepEnum.MACRO_PLACEMENT.value, "dreamplace"), (StepEnum.POST_FLOORPLAN.value, "ecc"), @@ -150,10 +151,10 @@ def test_failed_synthesis_lec_is_persisted_as_incomplete(monkeypatch, tmp_path): workspace = Workspace(directory=tmp_path) workspace.flow.path = tmp_path / "flow.json" workspace.flow.data = { - "steps": [{"name": StepEnum.LEC.value, "tool": "yosys_lec", "state": "Unstart"}] + "steps": [{"name": SkippableStepEnum.LEC.value, "tool": "yosys_lec", "state": "Unstart"}] } workspace.flow.path.write_text(json.dumps(workspace.flow.data), encoding="utf-8") - workspace_step = EccStep(name=StepEnum.LEC.value, directory=tmp_path, tool="yosys_lec") + workspace_step = EccStep(name=SkippableStepEnum.LEC.value, directory=tmp_path, tool="yosys_lec") engine_flow = EngineFlow(workspace) engine_flow.workspace_steps = [workspace_step] engine_flow.engine_db = SimpleNamespace(engine=None) @@ -175,27 +176,29 @@ def test_run_steps_stops_after_synthesis_lec_failure(monkeypatch, tmp_path): workspace = Workspace(directory=tmp_path) workspace.flow.data = { "steps": [ - {"name": StepEnum.LEC.value, "tool": "yosys_lec", "state": "Unstart"}, + {"name": SkippableStepEnum.LEC.value, "tool": "yosys_lec", "state": "Unstart"}, {"name": StepEnum.FLOORPLAN.value, "tool": "ecc", "state": "Unstart"}, ] } engine_flow = EngineFlow(workspace) engine_flow.workspace_steps = [ - EccStep(name=StepEnum.LEC.value, directory=tmp_path, tool="yosys_lec"), + EccStep(name=SkippableStepEnum.LEC.value, directory=tmp_path, tool="yosys_lec"), EccStep(name=StepEnum.FLOORPLAN.value, directory=tmp_path, tool="ecc"), ] calls = [] def fake_run_step(step, **_kwargs): calls.append(step.name) - return StateEnum.Imcomplete if step.name == StepEnum.LEC.value else StateEnum.Success + if step.name == SkippableStepEnum.LEC.value: + return StateEnum.Imcomplete + return StateEnum.Success monkeypatch.setattr(engine_flow, "run_step", fake_run_step) monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) monkeypatch.setattr(flow_module, "log_flow", lambda **_kwargs: None) assert engine_flow.run_steps() is False - assert calls == [StepEnum.LEC.value] + assert calls == [SkippableStepEnum.LEC.value] def test_check_step_result_synthesis_uses_common_verilog(tmp_path): @@ -289,7 +292,7 @@ def test_check_step_result_timing_opt_does_not_require_gds(tmp_path): (tmp_path / "gcd.def").write_text("") (tmp_path / "gcd.v").write_text("") step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, output=EccOutput(def_=tmp_path / "gcd.def", verilog=tmp_path / "gcd.v"), ) # gds intentionally absent; timing-opt result must still succeed. @@ -759,7 +762,7 @@ def test_sizer_state_enum_return_keeps_success_contract(self, monkeypatch, tmp_p verilog_path.write_text("module gcd; endmodule\n", encoding="utf-8") engine_flow, workspace, workspace_step = self._make_flow( tmp_path, - StepEnum.TIMING_OPT.value, + SkippableStepEnum.TIMING_OPT.value, "sizer", EccOutput(def_=def_path, verilog=verilog_path), ) diff --git a/test/tools/ecc/test_module.py b/test/tools/ecc/test_module.py index 05c36392..d0ef4013 100644 --- a/test/tools/ecc/test_module.py +++ b/test/tools/ecc/test_module.py @@ -8,7 +8,7 @@ import pytest import chipcompiler.utility as chipcompiler_utility -from chipcompiler.data import OriginDesign, StepEnum, Workspace +from chipcompiler.data import OriginDesign, SkippableStepEnum, StepEnum, Workspace from chipcompiler.tools.ecc import metrics as ecc_metrics from chipcompiler.tools.ecc import plot as ecc_plot from chipcompiler.tools.ecc import service as ecc_service @@ -2539,19 +2539,19 @@ def test_ecc_builder_uses_explicit_step_directory(tmp_path): step = build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=tmp_path / "input.def", input_verilog=tmp_path / "input.v", tool="sizer", step_directory=step_directory, ) - assert step.name == StepEnum.TIMING_OPT.value + assert step.name == SkippableStepEnum.TIMING_OPT.value assert step.directory == step_directory assert isinstance(step.directory, Path) assert step.output.dir == step_directory / "output" - assert step.data.steps[StepEnum.TIMING_OPT.value] == step_directory / "data" / "to" - assert step.log.file == step_directory / "log" / f"{StepEnum.TIMING_OPT.value}.log" + assert step.data.steps[SkippableStepEnum.TIMING_OPT.value] == step_directory / "data" / "to" + assert step.log.file == step_directory / "log" / f"{SkippableStepEnum.TIMING_OPT.value}.log" assert str(step.output.dir) == f"{step_directory}/output" - assert str(step.data.steps[StepEnum.TIMING_OPT.value]) == f"{step_directory}/data/to" - assert str(step.log.file) == f"{step_directory}/log/{StepEnum.TIMING_OPT.value}.log" + assert str(step.data.steps[SkippableStepEnum.TIMING_OPT.value]) == f"{step_directory}/data/to" + assert str(step.log.file) == f"{step_directory}/log/{SkippableStepEnum.TIMING_OPT.value}.log" diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index eaa7909f..7150de5f 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -13,6 +13,7 @@ EccStep, OriginDesign, Parameters, + SkippableStepEnum, StateEnum, StepEnum, StepInput, @@ -386,7 +387,7 @@ def test_create_db_engine_reads_replaced_step_input_despite_db(tmp_path, monkeyp config={"db": tmp_path / "config" / "db_ecc.json"}, ) step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, input=StepInput( def_=staging_def, verilog=staging_verilog, @@ -421,7 +422,7 @@ def test_create_db_engine_raises_and_closes_when_def_master_resolution_fails(tmp config={"db": tmp_path / "config" / "db_ecc.json"}, ) step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, input=StepInput(def_=design_def, verilog=tmp_path / "origin" / "gcd.v", db=None), data=EccData(dir=tmp_path / "timing_optimization_sizer" / "data"), feature=EccFeature(dir=tmp_path / "timing_optimization_sizer" / "feature"), @@ -492,7 +493,7 @@ def test_create_db_engine_without_input_db_does_not_retry_load_design(tmp_path, logger=FakeLogger(), ) step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, input=StepInput(def_=design_def, db=None), data=EccData(dir=tmp_path / "timing_optimization_sizer" / "data"), feature=EccFeature(dir=tmp_path / "timing_optimization_sizer" / "feature"), diff --git a/test/tools/ecc/test_signoff_checklist.py b/test/tools/ecc/test_signoff_checklist.py index 3e7690a8..ab350714 100644 --- a/test/tools/ecc/test_signoff_checklist.py +++ b/test/tools/ecc/test_signoff_checklist.py @@ -7,6 +7,7 @@ EccStep, OriginDesign, Parameters, + SkippableStepEnum, StateEnum, StepEnum, StepMetrics, @@ -97,9 +98,11 @@ def test_lec_failure_blocks_export_for_both_lec_steps(monkeypatch, tmp_path): ) workspace = Workspace(directory=tmp_path) - synthesis_item = _lec_artifact_items(workspace, StepEnum.LEC.value, result, None, None)[0] + synthesis_item = _lec_artifact_items( + workspace, SkippableStepEnum.LEC.value, result, None, None + )[0] post_route_item = _lec_artifact_items( - workspace, StepEnum.POST_ROUTE_LEC.value, result, None, None + workspace, SkippableStepEnum.POST_ROUTE_LEC.value, result, None, None )[0] for item in (synthesis_item, post_route_item): @@ -574,7 +577,7 @@ def test_home_checklist_flow_completed_tracks_final_harden_state(tmp_path): StepEnum.DRC, StepEnum.LVS, StepEnum.FILLER, - StepEnum.POST_ROUTE_LEC, + SkippableStepEnum.POST_ROUTE_LEC, StepEnum.RCX, StepEnum.STA, ) @@ -636,7 +639,7 @@ def test_home_checklist_uses_origin_golden_when_flow_has_no_synthesis(tmp_path): StepEnum.DRC, StepEnum.LVS, StepEnum.FILLER, - StepEnum.POST_ROUTE_LEC, + SkippableStepEnum.POST_ROUTE_LEC, StepEnum.RCX, StepEnum.STA, StepEnum.HARDEN, @@ -735,7 +738,7 @@ def test_home_checklist_uses_current_post_route_lec_result_not_stale_snapshot(tm for step in ( StepEnum.FILLER, StepEnum.LVS, - StepEnum.POST_ROUTE_LEC, + SkippableStepEnum.POST_ROUTE_LEC, StepEnum.HARDEN, ) ] diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index 1907f309..9d72d6e4 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -3,7 +3,15 @@ import pytest -from chipcompiler.data import EccData, EccStep, LogPaths, OriginDesign, StepEnum, Workspace +from chipcompiler.data import ( + EccData, + EccStep, + LogPaths, + OriginDesign, + SkippableStepEnum, + StepEnum, + Workspace, +) from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule, DreamplaceRunMode from chipcompiler.tools.ecc_dreamplace.service import get_step_info from chipcompiler.utility import json_write @@ -310,18 +318,18 @@ def fake_run(self, *, mode: DreamplaceRunMode) -> bool: monkeypatch.setattr(DreamplaceModule, "_run", fake_run) legalization = _module_for_owner(tmp_path, StepEnum.LEGALIZATION.value) - timing_opt = _module_for_owner(tmp_path, StepEnum.TIMING_OPT.value) + timing_opt = _module_for_owner(tmp_path, SkippableStepEnum.TIMING_OPT.value) placement = _module_for_owner(tmp_path, StepEnum.PLACEMENT.value) assert legalization.run_legalization() is True assert timing_opt.run_legalization() is True assert placement.run_legalization() is False - assert seen == [StepEnum.LEGALIZATION.value, StepEnum.TIMING_OPT.value] + assert seen == [StepEnum.LEGALIZATION.value, SkippableStepEnum.TIMING_OPT.value] def test_timing_opt_legalize_log_does_not_reuse_step_log(tmp_path): legalization = _module_for_owner(tmp_path, StepEnum.LEGALIZATION.value) - timing_opt = _module_for_owner(tmp_path, StepEnum.TIMING_OPT.value) + timing_opt = _module_for_owner(tmp_path, SkippableStepEnum.TIMING_OPT.value) assert legalization._file_handler_path(mode=DreamplaceRunMode.LEGALIZATION) == str( tmp_path / "step.log" @@ -347,7 +355,7 @@ def test_dreamplace_run_step_ignores_timing_opt(tmp_path, monkeypatch): monkeypatch.setattr(dreamplace_runner, "run_legalization", lambda **kwargs: True) workspace = Workspace(directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd")) - step = EccStep(name=StepEnum.TIMING_OPT.value) + step = EccStep(name=SkippableStepEnum.TIMING_OPT.value) assert dreamplace_runner.run_step(workspace, step) is False @@ -356,7 +364,7 @@ def test_legalize_layout_rebuilds_from_sources_and_closes_on_failure(tmp_path, m from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule - module = _module_for_owner(tmp_path, StepEnum.TIMING_OPT.value) + module = _module_for_owner(tmp_path, SkippableStepEnum.TIMING_OPT.value) staging_def = tmp_path / "sizer.def.gz" staging_verilog = tmp_path / "sizer.v.gz" created = [] @@ -392,7 +400,7 @@ def fake_create_db_engine(workspace, load_step): is None ) assert created == [ - (staging_def, staging_verilog, None, StepEnum.TIMING_OPT.value, module.workspace) + (staging_def, staging_verilog, None, SkippableStepEnum.TIMING_OPT.value, module.workspace) ] assert closed == [True] @@ -401,7 +409,7 @@ def test_legalize_layout_returns_none_without_dreamplace_config(tmp_path, monkey from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner workspace = Workspace(directory=str(tmp_path / "workspace"), design=OriginDesign(name="gcd")) - step = EccStep(name=StepEnum.TIMING_OPT.value) + step = EccStep(name=SkippableStepEnum.TIMING_OPT.value) monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) assert ( @@ -429,10 +437,10 @@ def test_legalize_layout_fills_missing_dreamplace_config_without_clobbering(tmp_ config={"db": workspace_dir / "config" / "db_ecc.json"}, ) step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, data=EccData( dir=tmp_path / "data", - steps={StepEnum.TIMING_OPT.value: tmp_path / "data" / "to"}, + steps={SkippableStepEnum.TIMING_OPT.value: tmp_path / "data" / "to"}, ), log=LogPaths(file=tmp_path / "step.log"), ) @@ -462,7 +470,7 @@ def test_legalize_layout_returns_engine_when_legalize_succeeds(tmp_path, monkeyp from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule - module = _module_for_owner(tmp_path, StepEnum.TIMING_OPT.value) + module = _module_for_owner(tmp_path, SkippableStepEnum.TIMING_OPT.value) engine = SimpleNamespace(close=lambda: (_ for _ in ()).throw(AssertionError("closed"))) monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) diff --git a/test/tools/ecc_sizer/test_engine_flow.py b/test/tools/ecc_sizer/test_engine_flow.py index 6f80084a..b5b600b9 100644 --- a/test/tools/ecc_sizer/test_engine_flow.py +++ b/test/tools/ecc_sizer/test_engine_flow.py @@ -3,7 +3,7 @@ from pathlib import Path from types import SimpleNamespace -from chipcompiler.data import EccOutput, EccStep, StateEnum, StepEnum, Workspace +from chipcompiler.data import EccOutput, EccStep, SkippableStepEnum, StateEnum, StepEnum, Workspace from ._sizer_helpers import _sizer_runtime, _subflow_states, _workspace @@ -17,7 +17,7 @@ def test_timing_opt_step_result_does_not_require_gds(tmp_path): output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, tool="sizer", output=EccOutput( def_=output_def, @@ -39,7 +39,7 @@ def test_timing_opt_step_result_requires_declared_geometry_manifest(tmp_path): output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, tool="sizer", output=EccOutput( def_=output_def, @@ -67,7 +67,7 @@ def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monk workspace.flow.data = { "steps": [ { - "name": StepEnum.TIMING_OPT.value, + "name": SkippableStepEnum.TIMING_OPT.value, "tool": "sizer", "state": StateEnum.Unstart.value, }, @@ -80,7 +80,7 @@ def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monk } sizer_step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, tool="sizer", output=EccOutput( def_=tmp_path / "sizer.def", @@ -163,14 +163,14 @@ def test_engine_flow_clears_cached_db_after_incomplete_sizer_step(tmp_path, monk workspace.flow.data = { "steps": [ { - "name": StepEnum.TIMING_OPT.value, + "name": SkippableStepEnum.TIMING_OPT.value, "tool": "sizer", "state": StateEnum.Unstart.value, } ] } sizer_step = EccStep( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, tool="sizer", output=EccOutput( def_=tmp_path / "sizer.def", @@ -212,7 +212,7 @@ def test_legacy_one_stage_success_is_invalidated_before_skip(tmp_path, monkeypat flow_data = { "steps": [ { - "name": StepEnum.TIMING_OPT.value, + "name": SkippableStepEnum.TIMING_OPT.value, "tool": "sizer", "state": StateEnum.Success.value, }, @@ -228,7 +228,7 @@ def test_legacy_one_stage_success_is_invalidated_before_skip(tmp_path, monkeypat step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -272,7 +272,7 @@ def test_legacy_one_stage_success_is_invalidated_before_skip(tmp_path, monkeypat engine_flow = EngineFlow(workspace) assert not engine_flow.check_state( - name=StepEnum.TIMING_OPT.value, + name=SkippableStepEnum.TIMING_OPT.value, tool="sizer", state=StateEnum.Success, ) diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index 2bd2ef49..c6b05909 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -6,7 +6,7 @@ import pytest from rosettakit.errors import ValidationError -from chipcompiler.data import StepEnum +from chipcompiler.data import SkippableStepEnum from ._sizer_helpers import _sizer_runtime, _workspace @@ -20,7 +20,7 @@ def test_sizer_step_config_writes_env_and_cmd_files(tmp_path, monkeypatch): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", ) @@ -50,11 +50,11 @@ def test_sizer_step_config_writes_env_and_cmd_files(tmp_path, monkeypatch): assert "-outputPath ." in cmd_text expected_def_out = os.path.relpath( sizer_builder.sizer_staging_def(step), - step.data.steps[StepEnum.TIMING_OPT.value], + step.data.steps[SkippableStepEnum.TIMING_OPT.value], ) expected_verilog_out = os.path.relpath( sizer_builder.sizer_staging_verilog(step), - step.data.steps[StepEnum.TIMING_OPT.value], + step.data.steps[SkippableStepEnum.TIMING_OPT.value], ) assert f"-def_out_path {expected_def_out}" in cmd_text assert f"-verilog_out_path {expected_verilog_out}" in cmd_text @@ -83,7 +83,7 @@ def test_sizer_metrics_write_qor_files_from_db_summary(tmp_path): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", ) @@ -119,7 +119,7 @@ def test_sizer_metrics_write_qor_files_from_db_summary(tmp_path): assert step.analysis.qor_summary is not None assert step.analysis.qor_summary.is_file() payload = json.loads(step.analysis.qor_metrics.read_text(encoding="utf-8")) - assert payload["step"] == StepEnum.TIMING_OPT.value + assert payload["step"] == SkippableStepEnum.TIMING_OPT.value assert any(record.get("id") == "die_area" for record in payload["metrics"]) with open(str(step.subflow.path), encoding="utf-8") as file: subflow = json.load(file) @@ -145,7 +145,7 @@ def test_sizer_config_preserves_runtime_parseable_order(tmp_path, monkeypatch): step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=str(tmp_path / "inputs" / "input_def.def"), input_verilog=str(tmp_path / "inputs" / "input_rtl.v"), ) @@ -166,11 +166,11 @@ def test_sizer_config_preserves_runtime_parseable_order(tmp_path, monkeypatch): expected_def_out = os.path.relpath( sizer_builder.sizer_staging_def(step), - step.data.steps[StepEnum.TIMING_OPT.value], + step.data.steps[SkippableStepEnum.TIMING_OPT.value], ) expected_verilog_out = os.path.relpath( sizer_builder.sizer_staging_verilog(step), - step.data.steps[StepEnum.TIMING_OPT.value], + step.data.steps[SkippableStepEnum.TIMING_OPT.value], ) assert cmd_lines == [ "-useOpenSTA", @@ -193,7 +193,7 @@ def test_sizer_cmd_omits_missing_input_paths(tmp_path): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=None, input_verilog=None, ) @@ -214,7 +214,7 @@ def test_sizer_config_rejects_whitespace_paths_unsupported_by_runtime(tmp_path, workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=str(tmp_path / "inputs" / "input def.def"), input_verilog="input.v", ) @@ -246,7 +246,7 @@ def test_sizer_config_omits_empty_optional_paths(tmp_path, monkeypatch): workspace.pdk.spef = "" step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="", input_verilog="", ) @@ -269,7 +269,7 @@ def test_sizer_step_declares_db_geometry_and_keeps_standard_dirs(tmp_path): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", ) @@ -277,9 +277,9 @@ def test_sizer_step_declares_db_geometry_and_keeps_standard_dirs(tmp_path): assert isinstance(step.output.db, Path) assert step.output.geometry is not None assert step.output.geometry_manifest is not None - assert step.name == StepEnum.TIMING_OPT.value + assert step.name == SkippableStepEnum.TIMING_OPT.value assert step.directory.name == "timing_optimization_sizer" - assert not str(step.directory).endswith(f"{StepEnum.TIMING_OPT.value}_sizer") + assert not str(step.directory).endswith(f"{SkippableStepEnum.TIMING_OPT.value}_sizer") assert isinstance(step.directory, Path) assert " " not in os.path.basename(str(step.output.def_)) assert " " not in os.path.basename(str(step.output.verilog)) @@ -310,7 +310,7 @@ def test_sizer_step_keeps_caller_input_paths(tmp_path): input_verilog = f"{workspace.directory}/Timing optimization_sizer_inputs/input.v" step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=input_def, input_verilog=input_verilog, ) @@ -329,7 +329,7 @@ def test_sizer_step_keeps_caller_output_paths_that_share_old_prefix(tmp_path): output_verilog = f"{workspace.directory}/Timing optimization_sizer_outputs/output.v" step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", output_def=output_def, @@ -469,7 +469,7 @@ def test_sizer_step_info_surfaces_include_step_local_config(tmp_path, monkeypatc workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", ) @@ -512,7 +512,7 @@ def test_sizer_build_step_config_rewrites_legacy_one_stage_subflow(tmp_path, mon workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def="input.def", input_verilog="input.v", ) diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 99263b49..0c7fee72 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -4,7 +4,7 @@ from pathlib import Path from types import SimpleNamespace -from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data import SkippableStepEnum, StateEnum from ._sizer_helpers import ( ExplodingEccModule, @@ -23,7 +23,7 @@ def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, mon workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -69,7 +69,7 @@ def fake_run(command, cwd, stdout, stderr, check): "-f", str(step.script.sizer_cmd), ], - str(step.data.steps[StepEnum.TIMING_OPT.value]), + str(step.data.steps[SkippableStepEnum.TIMING_OPT.value]), None, subprocess.STDOUT, False, @@ -84,7 +84,7 @@ def test_sizer_runner_marks_subflow_invalid_when_tool_or_config_missing(tmp_path workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -113,7 +113,7 @@ def test_sizer_runner_does_not_run_sizer_when_dreamplace_is_missing(tmp_path, mo workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -146,7 +146,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -259,7 +259,7 @@ def test_sizer_runner_inherits_captured_stdio_instead_of_truncating_step_log( workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -298,7 +298,7 @@ def test_public_sizer_run_marks_invalid_when_tool_missing(tmp_path, monkeypatch) workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -323,7 +323,7 @@ def test_public_sizer_run_marks_invalid_when_runtime_missing(tmp_path, monkeypat workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) diff --git a/test/tools/ecc_sizer/test_runner_cleanup.py b/test/tools/ecc_sizer/test_runner_cleanup.py index 6308ba0a..702b304c 100644 --- a/test/tools/ecc_sizer/test_runner_cleanup.py +++ b/test/tools/ecc_sizer/test_runner_cleanup.py @@ -4,7 +4,7 @@ import pytest -from chipcompiler.data import StateEnum, StepEnum, Workspace +from chipcompiler.data import SkippableStepEnum, StateEnum, Workspace from ._sizer_helpers import ( FakeLegalizeModule, @@ -24,7 +24,7 @@ def test_sizer_success_legalize_failure_leaves_published_outputs_empty(tmp_path, workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -58,7 +58,7 @@ def test_sizer_save_data_failure_deletes_partial_outputs(tmp_path, monkeypatch): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -94,7 +94,7 @@ def test_sizer_save_data_failure_deletes_feature_report_and_image(tmp_path, monk workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -133,7 +133,7 @@ def test_sizer_closes_engine_when_published_cleanup_fails(tmp_path, monkeypatch) workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -176,7 +176,7 @@ def test_sizer_does_not_legalize_when_staging_cleanup_fails(tmp_path, monkeypatc workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -221,7 +221,7 @@ def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -261,7 +261,7 @@ def test_sizer_rerun_does_not_legalize_stale_staging_when_sizer_writes_nothing( workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -300,7 +300,7 @@ def test_sizer_save_data_exception_deletes_partial_outputs(tmp_path, monkeypatch workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) diff --git a/test/yosys_lec/test_tools_yosys_lec.py b/test/yosys_lec/test_tools_yosys_lec.py index b5abd958..cfc55019 100644 --- a/test/yosys_lec/test_tools_yosys_lec.py +++ b/test/yosys_lec/test_tools_yosys_lec.py @@ -9,6 +9,7 @@ OriginDesign, OutputPaths, Parameters, + SkippableStepEnum, StateEnum, StepEnum, Workspace, @@ -98,7 +99,7 @@ def test_lec_builder_derives_golden_from_gate_netlist_and_creates_workspace(tmp_ step = builder.build_step( workspace=workspace, - step_name=StepEnum.LEC.value, + step_name=SkippableStepEnum.LEC.value, input_def=None, input_verilog=gate, ) @@ -122,7 +123,7 @@ def test_lec_builder_accepts_explicit_golden_netlist(tmp_path): step = builder.build_step( workspace=workspace, - step_name=StepEnum.POST_ROUTE_LEC.value, + step_name=SkippableStepEnum.POST_ROUTE_LEC.value, input_def=None, input_verilog=gate, input_db=golden, @@ -142,7 +143,7 @@ def test_lec_build_step_config_writes_models_and_repo_local_script(tmp_path): step = builder.build_step( workspace=workspace, - step_name=StepEnum.LEC.value, + step_name=SkippableStepEnum.LEC.value, input_def=None, input_verilog=gate, ) @@ -171,7 +172,7 @@ def test_lec_runner_marks_success_from_yosys_status(tmp_path, monkeypatch): step = builder.build_step( workspace=workspace, - step_name=StepEnum.LEC.value, + step_name=SkippableStepEnum.LEC.value, input_def=None, input_verilog=gate, ) @@ -212,7 +213,7 @@ def test_lec_runner_writes_incomplete_result_on_failure(tmp_path, monkeypatch): _write_gcd_netlist_pair(gate) step = builder.build_step( workspace=workspace, - step_name=StepEnum.LEC.value, + step_name=SkippableStepEnum.LEC.value, input_def=None, input_verilog=gate, ) @@ -263,7 +264,7 @@ def test_engine_flow_accepts_lec_result_json(tmp_path): ) ) step = YosysLecStep( - name=StepEnum.LEC.value, + name=SkippableStepEnum.LEC.value, input=SimpleNamespace(golden_verilog=golden, gate_verilog=gate), output=OutputPaths(json=result_json), ) @@ -271,7 +272,7 @@ def test_engine_flow_accepts_lec_result_json(tmp_path): assert EngineFlow(workspace=None).check_step_result(step) is True incomplete = tmp_path / "lec_incomplete.json" incomplete.write_text('{"status": "incomplete"}\n') - failed = YosysLecStep(name=StepEnum.LEC.value, output=OutputPaths(json=incomplete)) + failed = YosysLecStep(name=SkippableStepEnum.LEC.value, output=OutputPaths(json=incomplete)) assert EngineFlow(workspace=None).check_step_result(failed) is False @@ -299,7 +300,7 @@ def test_engine_flow_rejects_stale_lec_result_when_netlist_changes(tmp_path): ) ) step = YosysLecStep( - name=StepEnum.LEC.value, + name=SkippableStepEnum.LEC.value, input=SimpleNamespace(golden_verilog=golden, gate_verilog=gate), output=OutputPaths(json=result_json), ) @@ -325,7 +326,7 @@ def test_engine_flow_rejects_legacy_proven_json_without_digests(tmp_path): ) ) step = YosysLecStep( - name=StepEnum.LEC.value, + name=SkippableStepEnum.LEC.value, input=SimpleNamespace(golden_verilog=golden, gate_verilog=gate), output=OutputPaths(json=result_json), ) @@ -354,7 +355,7 @@ def test_engine_flow_rejects_legacy_proven_json_without_sizes(tmp_path): ) ) step = YosysLecStep( - name=StepEnum.LEC.value, + name=SkippableStepEnum.LEC.value, input=SimpleNamespace(golden_verilog=golden, gate_verilog=gate), output=OutputPaths(json=result_json), ) @@ -385,7 +386,7 @@ def test_engine_flow_rejects_bool_size_fields(tmp_path): ) ) step = YosysLecStep( - name=StepEnum.LEC.value, + name=SkippableStepEnum.LEC.value, input=SimpleNamespace(golden_verilog=golden, gate_verilog=gate), output=OutputPaths(json=result_json), ) @@ -400,7 +401,7 @@ def test_lec_runner_writes_incomplete_result_when_yosys_raises(tmp_path, monkeyp _write_gcd_netlist_pair(gate) step = builder.build_step( workspace=workspace, - step_name=StepEnum.LEC.value, + step_name=SkippableStepEnum.LEC.value, input_def=None, input_verilog=gate, ) @@ -433,10 +434,10 @@ def test_rtl2gds_flow_runs_post_route_lec_after_lvs_before_drc(): steps = build_rtl2gds_flow() step_names = [step[0] for step in steps] - lec_index = step_names.index(StepEnum.POST_ROUTE_LEC) + lec_index = step_names.index(SkippableStepEnum.POST_ROUTE_LEC) assert step_names.index(StepEnum.STA) < step_names.index(StepEnum.LVS) assert step_names.index(StepEnum.LVS) < lec_index < step_names.index(StepEnum.DRC) - assert steps[lec_index] == (StepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart) + assert steps[lec_index] == (SkippableStepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart) def test_rtl2gds_flow_keeps_unstable_synthesis_lec_disabled(): @@ -444,7 +445,7 @@ def test_rtl2gds_flow_keeps_unstable_synthesis_lec_disabled(): steps = build_rtl2gds_flow() step_names = [step[0] for step in steps] - assert StepEnum.LEC not in step_names + assert SkippableStepEnum.LEC not in step_names def test_engine_flow_wires_synthesis_lec_without_changing_physical_chain(tmp_path, monkeypatch): @@ -455,7 +456,11 @@ def test_engine_flow_wires_synthesis_lec_without_changing_physical_chain(tmp_pat workspace.flow.data = { "steps": [ {"name": StepEnum.SYNTHESIS.value, "tool": "yosys", "state": StateEnum.Unstart.value}, - {"name": StepEnum.LEC.value, "tool": "yosys_lec", "state": StateEnum.Unstart.value}, + { + "name": SkippableStepEnum.LEC.value, + "tool": "yosys_lec", + "state": StateEnum.Unstart.value, + }, {"name": StepEnum.FLOORPLAN.value, "tool": "ecc", "state": StateEnum.Unstart.value}, ] } @@ -528,7 +533,7 @@ def test_engine_flow_wires_post_route_lec_against_synthesis_gate(tmp_path, monke {"name": StepEnum.SYNTHESIS.value, "tool": "yosys", "state": StateEnum.Unstart.value}, {"name": StepEnum.ROUTING.value, "tool": "ecc", "state": StateEnum.Unstart.value}, { - "name": StepEnum.POST_ROUTE_LEC.value, + "name": SkippableStepEnum.POST_ROUTE_LEC.value, "tool": "yosys_lec", "state": StateEnum.Unstart.value, }, @@ -588,7 +593,7 @@ def fake_create_step( synth_step, route_step, lec_step, rcx_step = engine_flow.workspace_steps assert synth_step.name == StepEnum.SYNTHESIS.value assert route_step.name == StepEnum.ROUTING.value - assert lec_step.name == StepEnum.POST_ROUTE_LEC.value + assert lec_step.name == SkippableStepEnum.POST_ROUTE_LEC.value assert rcx_step.name == StepEnum.RCX.value assert lec_step.input.gate_verilog == route_step.output.verilog assert lec_step.input.golden_verilog == synth_step.output.verilog @@ -605,7 +610,7 @@ def test_engine_flow_wires_post_route_lec_to_origin_without_synthesis(tmp_path, {"name": StepEnum.FLOORPLAN.value, "tool": "ecc", "state": StateEnum.Unstart.value}, {"name": StepEnum.ROUTING.value, "tool": "ecc", "state": StateEnum.Unstart.value}, { - "name": StepEnum.POST_ROUTE_LEC.value, + "name": SkippableStepEnum.POST_ROUTE_LEC.value, "tool": "yosys_lec", "state": StateEnum.Unstart.value, }, From 09bc32a16a3e3bf9e8991406cfa23d7758699e13 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 15:32:35 +0800 Subject: [PATCH 03/19] feat(rtl2gds): resolve skip policy and filter ledgers at build time One authoritative resolver owns skip-policy semantics: resolve_skip_steps reads the skip_steps key from a flow config with presence semantics (absent key -> code default (lec,); explicit [] -> run everything), normalizes aliases, validates membership against the skippable step set in canonical chain order, and rejects invalid values. build_rtl2gds_flow gains a skip filter and restores the synthesis LEC into the canonical chain (reverting the #280 comment-out); build_flow_range filters before slicing so a skipped step cannot bound a range. Ledger creation paths (dynamic flow data, preset seeding, ledger-less rebuild) resolve the policy through the same resolver, keeping default behavior identical --- chipcompiler/cli/project/run_prepare.py | 8 ++- chipcompiler/data/__init__.py | 2 + chipcompiler/data/types.py | 7 +++ chipcompiler/data/workspace/__init__.py | 6 +- chipcompiler/rtl2gds/__init__.py | 4 ++ chipcompiler/rtl2gds/builder.py | 71 ++++++++++++++++++--- chipcompiler/runtime/workspace_api.py | 9 ++- test/cli/commands/conftest.py | 2 +- test/cli/commands/test_doctor.py | 2 +- test/cli/commands/test_report_step.py | 3 +- test/cli/commands/test_status.py | 3 +- test/cli/commands/test_workspace_range.py | 2 +- test/cli/params/test_commands.py | 6 +- test/cli/params/test_provenance.py | 6 +- test/cli/rendering/test_pretty.py | 2 +- test/data/test_workspace.py | 2 +- test/rtl2gds/test_builder.py | 76 +++++++++++++++++++++++ test/runtime/test_workspace_api.py | 2 +- test/yosys_lec/test_tools_yosys_lec.py | 5 +- 19 files changed, 190 insertions(+), 28 deletions(-) diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 32fddc16..8a041f61 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -457,7 +457,13 @@ def failed_workspace(reason: str | None) -> CommandResult: engine_flow = EngineFlow(workspace=workspace) flow_builders = rtl2gds_api.get_flow_builders() if not engine_flow.has_init(): - for step, tool, state in flow_builders[cfg.flow_preset](): + # No-arg preset builders stay canonical; the skip policy is + # applied to their output so every ledger-creation path + # filters through one resolver. + for step, tool, state in rtl2gds_api.filter_flow_steps( + flow_builders[cfg.flow_preset](), + rtl2gds_api.resolve_skip_steps(flow_config), + ): engine_flow.add_step(step=step, tool=tool, state=state) engine_flow.create_step_workspaces() diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py index 28727263..8e1fd907 100644 --- a/chipcompiler/data/__init__.py +++ b/chipcompiler/data/__init__.py @@ -18,6 +18,7 @@ step_storage_name, ) from .types import ( + DEFAULT_SKIP_STEPS, FINISHED_STEP_STATES, SkippableStepEnum, StateEnum, @@ -86,6 +87,7 @@ "create_workspace", "load_workspace", "create_default_sdc", + "DEFAULT_SKIP_STEPS", "Workspace", "WorkspaceStep", "WorkspaceStepBase", diff --git a/chipcompiler/data/types.py b/chipcompiler/data/types.py index 5cc13742..9c983342 100644 --- a/chipcompiler/data/types.py +++ b/chipcompiler/data/types.py @@ -5,6 +5,7 @@ """ from enum import Enum +from typing import Final class StepBaseEnum(Enum): @@ -60,6 +61,12 @@ def is_skippable(self) -> bool: return True +# Projects that declare no skip policy skip the synthesis LEC: the +# conservative default keeps pre-existing projects' ledgers unchanged. +# An explicit empty skip list is the only way to enable it. +DEFAULT_SKIP_STEPS: Final = (SkippableStepEnum.LEC.value,) + + _STEP_ENUMS: tuple[type[StepBaseEnum], ...] = (StepEnum, SkippableStepEnum) diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 5b9acdae..2419a3ec 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -285,7 +285,8 @@ def build_dynamic_flow_data(flow_config: dict | None) -> dict: A non-contiguous explicit selection degrades to the contiguous first..last range (with a log note) so flow.json and the [flow] target - always describe the same steps. + always describe the same steps. The config's skip policy is resolved + here: skipped steps never enter the ledger. """ if not isinstance(flow_config, dict) or not flow_config: return {} @@ -299,7 +300,8 @@ def build_dynamic_flow_data(flow_config: dict | None) -> dict: import chipcompiler.rtl2gds as rtl2gds_api - selected = rtl2gds_api.build_flow_range(selected_names[0], selected_names[-1]) + skip = rtl2gds_api.resolve_skip_steps(flow_config) + selected = rtl2gds_api.build_flow_range(selected_names[0], selected_names[-1], skip=skip) return { "steps": [ _flow_step_template( diff --git a/chipcompiler/rtl2gds/__init__.py b/chipcompiler/rtl2gds/__init__.py index 712ce910..9f08c9a4 100644 --- a/chipcompiler/rtl2gds/__init__.py +++ b/chipcompiler/rtl2gds/__init__.py @@ -3,8 +3,10 @@ build_rtl2gds_flow, build_syn_sta_flow, build_synthesis_lec_flow, + filter_flow_steps, get_flow_builders, normalize_flow_step, + resolve_skip_steps, ) __all__ = [ @@ -12,6 +14,8 @@ "build_rtl2gds_flow", "build_syn_sta_flow", "build_synthesis_lec_flow", + "filter_flow_steps", "get_flow_builders", "normalize_flow_step", + "resolve_skip_steps", ] diff --git a/chipcompiler/rtl2gds/builder.py b/chipcompiler/rtl2gds/builder.py index 56c9dec8..97da9464 100644 --- a/chipcompiler/rtl2gds/builder.py +++ b/chipcompiler/rtl2gds/builder.py @@ -1,15 +1,63 @@ #!/usr/bin/env python -from collections.abc import Callable +from collections.abc import Callable, Collection -from chipcompiler.data import SkippableStepEnum, StateEnum, StepBaseEnum, StepEnum +from chipcompiler.data import ( + DEFAULT_SKIP_STEPS, + SkippableStepEnum, + StateEnum, + StepBaseEnum, + StepEnum, +) +# Step values a project is allowed to exclude from its ledger. +SKIPPABLE_STEP_VALUES = frozenset(member.value for member in SkippableStepEnum) -def build_rtl2gds_flow() -> list: + +def resolve_skip_steps(flow_config: dict | None) -> tuple[str, ...]: + """The effective skip policy carried by a flow config. + + Presence-keyed: an absent ``skip_steps`` key yields the code default; + an explicitly empty list yields ``()`` (run every step — the only way + to enable the synthesis LEC). Entries accept the same aliases as step + ranges, must name skippable steps only, and normalize to canonical + step values in canonical chain order (idempotently). + """ + if not isinstance(flow_config, dict) or "skip_steps" not in flow_config: + return DEFAULT_SKIP_STEPS + raw = flow_config["skip_steps"] + if not isinstance(raw, list): + raise ValueError(f"skip_steps must be a list, not {type(raw).__name__}: {raw!r}") + requested = set() + for entry in raw: + if not isinstance(entry, str): + raise ValueError(f"skip_steps entries must be strings, not {entry!r}") + requested.add(normalize_flow_step(entry)) + illegal = sorted(requested - SKIPPABLE_STEP_VALUES) + if illegal: + legal = ", ".join(sorted(SKIPPABLE_STEP_VALUES)) + raise ValueError( + f"skip_steps names steps that cannot be skipped: {', '.join(illegal)}; " + f"skippable steps: {legal}" + ) + chain_names = [step.value for step, _tool, _state in build_rtl2gds_flow()] + return tuple(name for name in chain_names if name in requested) + + +def filter_flow_steps(steps: list, skip: Collection[str]) -> list: + """Drop the skipped step entries from a built step list, order untouched.""" + excluded = set(skip) + return [ + entry + for entry in steps + if (entry[0].value if isinstance(entry[0], StepBaseEnum) else str(entry[0])) not in excluded + ] + + +def build_rtl2gds_flow(*, skip: Collection[str] = ()) -> list: steps = [] steps.append((StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart)) - # LEC is still unstable; keep it disabled until it is reliable enough to enable. - # steps.append((SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart)) + steps.append((SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart)) steps.append((StepEnum.PRE_FLOORPLAN, "ecc", StateEnum.Unstart)) steps.append((StepEnum.MACRO_PLACEMENT, "dreamplace", StateEnum.Unstart)) steps.append((StepEnum.POST_FLOORPLAN, "ecc", StateEnum.Unstart)) @@ -26,7 +74,7 @@ def build_rtl2gds_flow() -> list: steps.append((StepEnum.DRC, "ecc", StateEnum.Unstart)) steps.append((StepEnum.HARDEN, "ecc", StateEnum.Unstart)) - return steps + return filter_flow_steps(steps, skip) def normalize_flow_step(value: str | StepBaseEnum) -> str: @@ -69,13 +117,20 @@ def normalize_flow_step(value: str | StepBaseEnum) -> str: return aliases.get(alias_key, token) -def build_flow_range(from_step: str | StepBaseEnum, to_step: str | StepBaseEnum) -> list: +def build_flow_range( + from_step: str | StepBaseEnum, + to_step: str | StepBaseEnum, + *, + skip: Collection[str] = (), +) -> list: """Return the inclusive canonical RTL-to-GDS range requested by a workspace. The RTL-to-GDS chain is owned by :func:`build_rtl2gds_flow`; partial flows are always slices of that chain rather than a second hand-maintained list. + Skipped steps are excluded from the chain first, so a skipped step cannot + serve as a range boundary (it is unknown in the filtered chain). """ - steps = build_rtl2gds_flow() + steps = build_rtl2gds_flow(skip=skip) names = [ step.value if isinstance(step, StepBaseEnum) else str(step) for step, _tool, _state in steps ] diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index ebb26ec7..af5b459e 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -2462,7 +2462,14 @@ def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): engine_flow = engine_api.EngineFlow(workspace=workspace) if not engine_flow.has_init(): - for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): + # Ledger-less rebuild: the workspace's persisted flow target carries + # the skip policy; the code default applies when none was persisted. + parameters_data = getattr(getattr(workspace, "parameters", None), "data", None) + persisted_flow = parameters_data.get("_flow") if isinstance(parameters_data, dict) else None + skip = rtl2gds_api.resolve_skip_steps( + persisted_flow if isinstance(persisted_flow, dict) else None + ) + for step, tool, state in rtl2gds_api.build_rtl2gds_flow(skip=skip): engine_flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index b16fce1c..2423e0b9 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -63,7 +63,7 @@ def fake_create_workspace(**kwargs): monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", diff --git a/test/cli/commands/test_doctor.py b/test/cli/commands/test_doctor.py index 2c67d7b9..3a9789c7 100644 --- a/test/cli/commands/test_doctor.py +++ b/test/cli/commands/test_doctor.py @@ -242,7 +242,7 @@ def test_preflight_components_mapping(self, monkeypatch): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [ + lambda *, skip=(): [ ("Synthesis", "yosys", "Unstart"), ("Floorplan", "ecc", "Unstart"), ("place", "dreamplace", "Unstart"), diff --git a/test/cli/commands/test_report_step.py b/test/cli/commands/test_report_step.py index df9e7033..acaa0cee 100644 --- a/test/cli/commands/test_report_step.py +++ b/test/cli/commands/test_report_step.py @@ -254,6 +254,7 @@ def step_args(tmp_path, *args, workspace="ws"): class TestStepOverview: def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys, plain_records): + from chipcompiler.data import DEFAULT_SKIP_STEPS from chipcompiler.rtl2gds.builder import build_rtl2gds_flow ws = str(tmp_path / "ws") @@ -262,7 +263,7 @@ def test_overview_includes_every_rtl2gds_step(self, tmp_path, capsys, plain_reco { "steps": [ {"name": step.value, "tool": tool, "state": state.value} - for step, tool, state in build_rtl2gds_flow() + for step, tool, state in build_rtl2gds_flow(skip=DEFAULT_SKIP_STEPS) ] }, ) diff --git a/test/cli/commands/test_status.py b/test/cli/commands/test_status.py index 95a09c10..f5b11bda 100644 --- a/test/cli/commands/test_status.py +++ b/test/cli/commands/test_status.py @@ -8,11 +8,12 @@ class TestStatus: def test_status_normalizes_every_rtl2gds_step( self, tmp_path, capsys, create_cli_project, create_flow_json, plain_records ): + from chipcompiler.data import DEFAULT_SKIP_STEPS from chipcompiler.rtl2gds.builder import build_rtl2gds_flow project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") - flow = build_rtl2gds_flow() + flow = build_rtl2gds_flow(skip=DEFAULT_SKIP_STEPS) create_flow_json( run_dir, [ diff --git a/test/cli/commands/test_workspace_range.py b/test/cli/commands/test_workspace_range.py index fe585e3b..8820dad9 100644 --- a/test/cli/commands/test_workspace_range.py +++ b/test/cli/commands/test_workspace_range.py @@ -30,7 +30,7 @@ def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution design_def, netlist = _set_design_inputs(project_dir) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("CTS", "ecc", "Unstart")], + lambda *, skip=(): [("CTS", "ecc", "Unstart")], ) rc = cli_main.run( diff --git a/test/cli/params/test_commands.py b/test/cli/params/test_commands.py index 74f6dba1..7e7f2faf 100644 --- a/test/cli/params/test_commands.py +++ b/test/cli/params/test_commands.py @@ -240,7 +240,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", @@ -294,7 +294,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", @@ -378,7 +378,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", diff --git a/test/cli/params/test_provenance.py b/test/cli/params/test_provenance.py index 2bcb8dcf..463c6b94 100644 --- a/test/cli/params/test_provenance.py +++ b/test/cli/params/test_provenance.py @@ -36,7 +36,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", @@ -102,7 +102,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", @@ -175,7 +175,7 @@ def fake_create(**kwargs): ) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", diff --git a/test/cli/rendering/test_pretty.py b/test/cli/rendering/test_pretty.py index 58e2739b..42e7ffea 100644 --- a/test/cli/rendering/test_pretty.py +++ b/test/cli/rendering/test_pretty.py @@ -261,7 +261,7 @@ def run_steps(self): monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) monkeypatch.setattr( "chipcompiler.cli.project.config._validate_pdk_contents", diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 3fd3b22f..01d07ba2 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -297,7 +297,7 @@ def test_load_workspace_restores_golden_from_persisted_flow_info( pdk="ics55", parameters=deepcopy(default_ics55_parameters), pdk_root=pdk_root, - flow_config={"start_step": "lec", "end_step": "lec"}, + flow_config={"start_step": "lec", "end_step": "lec", "skip_steps": []}, ) loaded = load_workspace(str(workspace_dir)) diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index 4fdf1b23..90ad9719 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -56,6 +56,7 @@ def test_build_rtl2gds_flow_is_the_complete_flow(): assert flow == [ (StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart), + (SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart), (StepEnum.PRE_FLOORPLAN, "ecc", StateEnum.Unstart), (StepEnum.MACRO_PLACEMENT, "dreamplace", StateEnum.Unstart), (StepEnum.POST_FLOORPLAN, "ecc", StateEnum.Unstart), @@ -74,6 +75,16 @@ def test_build_rtl2gds_flow_is_the_complete_flow(): ] +def test_build_rtl2gds_flow_skip_removes_exactly_the_skipped_steps(): + flow = builder_module.build_rtl2gds_flow(skip=("lec", SkippableStepEnum.TIMING_OPT.value)) + + step_names = [step.value for step, _tool, _state in flow] + assert SkippableStepEnum.LEC.value not in step_names + assert SkippableStepEnum.TIMING_OPT.value not in step_names + unfiltered = [step.value for step, _tool, _state in builder_module.build_rtl2gds_flow()] + assert step_names == [name for name in unfiltered if name not in {"lec", "Timing optimization"}] + + def test_build_flow_range_slices_the_canonical_chain(): flow = builder_module.build_flow_range("CTS", "route") @@ -103,3 +114,68 @@ def test_build_flow_range_exposes_split_floorplan_steps(): (StepEnum.MACRO_PLACEMENT, "dreamplace"), (StepEnum.POST_FLOORPLAN, "ecc"), ] + + +def test_build_flow_range_skip_excludes_steps_from_the_slice(): + flow = builder_module.build_flow_range("Synthesis", "preFloorplan", skip=("lec",)) + + assert [step for step, _tool, _state in flow] == [ + StepEnum.SYNTHESIS, + StepEnum.PRE_FLOORPLAN, + ] + + +def test_build_flow_range_rejects_skipped_step_as_boundary(): + with pytest.raises(ValueError, match="unknown flow step"): + builder_module.build_flow_range("Synthesis", "lec", skip=("lec",)) + + +def test_resolve_skip_steps_absent_key_yields_the_code_default(): + from chipcompiler.data import DEFAULT_SKIP_STEPS + + assert builder_module.resolve_skip_steps(None) == DEFAULT_SKIP_STEPS + assert builder_module.resolve_skip_steps({}) == DEFAULT_SKIP_STEPS + assert builder_module.resolve_skip_steps({"start_step": "Synthesis"}) == DEFAULT_SKIP_STEPS + assert (SkippableStepEnum.LEC.value,) == DEFAULT_SKIP_STEPS + + +def test_resolve_skip_steps_explicit_empty_list_runs_everything(): + assert builder_module.resolve_skip_steps({"skip_steps": []}) == () + + +@pytest.mark.parametrize( + "raw,expected", + [ + (["lec"], ("lec",)), + (["LEC"], ("lec",)), + (["lec", "lec"], ("lec",)), + (["postRouteLec", "lec"], ("lec", "postRouteLec")), + (["TimingOpt", "lec", "postlec"], ("lec", "Timing optimization", "postRouteLec")), + ], +) +def test_resolve_skip_steps_normalizes_aliases_in_canonical_order(raw, expected): + assert builder_module.resolve_skip_steps({"skip_steps": raw}) == expected + # Normalization is idempotent: feeding the normalized tuple back is a no-op. + assert builder_module.resolve_skip_steps({"skip_steps": list(expected)}) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "lec", + None, + [1], + ["lec", "route"], + ["bogus"], + ], +) +def test_resolve_skip_steps_rejects_invalid_values(raw): + with pytest.raises(ValueError, match="skip_steps"): + builder_module.resolve_skip_steps({"skip_steps": raw}) + + +def test_filter_flow_steps_removes_entries_without_reordering(): + steps = builder_module.build_synthesis_lec_flow() + + assert builder_module.filter_flow_steps(steps, ()) == steps + assert builder_module.filter_flow_steps(steps, ("lec",)) == steps[:1] diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index 427fdc7c..562637cf 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -222,7 +222,7 @@ def fake_load_workspace(directory): monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( "chipcompiler.rtl2gds.build_rtl2gds_flow", - lambda: [("Synthesis", "yosys", "Unstart")], + lambda *, skip=(): [("Synthesis", "yosys", "Unstart")], ) ws = tmp_path / "workspace" diff --git a/test/yosys_lec/test_tools_yosys_lec.py b/test/yosys_lec/test_tools_yosys_lec.py index cfc55019..a0f5633d 100644 --- a/test/yosys_lec/test_tools_yosys_lec.py +++ b/test/yosys_lec/test_tools_yosys_lec.py @@ -440,12 +440,13 @@ def test_rtl2gds_flow_runs_post_route_lec_after_lvs_before_drc(): assert steps[lec_index] == (SkippableStepEnum.POST_ROUTE_LEC, "yosys_lec", StateEnum.Unstart) -def test_rtl2gds_flow_keeps_unstable_synthesis_lec_disabled(): +def test_rtl2gds_flow_restores_synthesis_lec_into_the_canonical_chain(): from chipcompiler.rtl2gds import build_rtl2gds_flow steps = build_rtl2gds_flow() step_names = [step[0] for step in steps] - assert SkippableStepEnum.LEC not in step_names + assert SkippableStepEnum.LEC in step_names + assert step_names.index(SkippableStepEnum.LEC) == step_names.index(StepEnum.SYNTHESIS) + 1 def test_engine_flow_wires_synthesis_lec_without_changing_physical_chain(tmp_path, monkeypatch): From 8cb936c677303c60546cc95c3779ee43e86e623e Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 22:37:48 +0800 Subject: [PATCH 04/19] feat(cli): carry skip_steps through the configuration surfaces project.json workspaces[].skip_steps and ecc.toml [flow] skip_steps are both parsed and validated (invalid values fail the manifest load / config validation before any mutation). The declared spelling is preserved verbatim on round-trip; only the persisted workspace policy is normalized. Precedence for this one key is skip-specific: project.json outranks ecc.toml (including an explicit empty list); every other field keeps the ecc.toml-wins contract. Workspace [flow] sections accept a normalized skip_steps (three states distinct: absent / [] / list) and policy-only sections are valid. ecc init materializes the default skip_steps = ["lec"] with an explanatory comment into generated ecc.toml; legacy-run migration carries a workspace's persisted policy into its manifest entry --- chipcompiler/cli/command_handlers/project.py | 30 ++- chipcompiler/cli/project/config.py | 17 ++ chipcompiler/cli/project/effective_config.py | 42 ++++- chipcompiler/cli/project/migrate_plan.py | 38 ++++ chipcompiler/cli/project/run_prepare.py | 21 ++- chipcompiler/data/workspace_config.py | 42 +++-- chipcompiler/project/manifest.py | 23 +++ chipcompiler/project/manifest_write.py | 10 +- test/cli/commands/test_migrate.py | 62 +++++++ test/cli/project/test_skip_steps_config.py | 185 +++++++++++++++++++ test/data/test_workspace_config.py | 59 ++++++ 11 files changed, 509 insertions(+), 20 deletions(-) create mode 100644 test/cli/project/test_skip_steps_config.py diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 23d30117..71c469b1 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -58,6 +58,8 @@ def init(command_input: InitInput, ctx: CommandContext) -> CommandResult: [flow] # preset: rtl2gds | syn_sta | synthesis_lec preset = "rtl2gds" +# LEC is skipped by default; clear the list to enable it. +skip_steps = ["lec"] """ with open(config_path, "w") as f: @@ -356,6 +358,7 @@ def error(kind: str, **fields) -> CommandResult: ) from chipcompiler.cli.project import effective_config + from chipcompiler.cli.project.effective_config import flow_config_selects_steps if ctx.project_state == "manifest": resolved_cfg = effective_config.resolve_effective_config(ctx, command_input.workspace, cfg) @@ -363,6 +366,17 @@ def error(kind: str, **fields) -> CommandResult: return resolved_cfg cfg, flow_config, entry_warnings = resolved_cfg layer_warnings.extend(entry_warnings) + else: + # Virgin projects have no manifest layer; an ecc.toml skip policy + # still rides on the flow config (policy-only when no range applies). + from chipcompiler.cli.project.effective_config import _attach_skip_steps + + skip_steps = ( + list(cfg.flow_skip_steps) + if "flow.skip_steps" in cfg._explicit_keys and cfg.flow_skip_steps is not None + else None + ) + flow_config = _attach_skip_steps(flow_config, skip_steps) flow_builders = rtl2gds_api.get_flow_builders() effective_preset = command_input.preset or cfg.flow_preset @@ -377,24 +391,32 @@ def error(kind: str, **fields) -> CommandResult: ) ] ) + # The resolved skip policy survives every target override below (the + # policy comes from configuration, never from the target spelling). + skip_policy = flow_config.get("skip_steps") if isinstance(flow_config, dict) else None if command_input.preset is not None: # The explicit CLI selection takes precedence over a manifest range # for this invocation without changing either project config file. cfg.flow_preset = effective_preset cfg.manifest_driven = False - flow_config = None + flow_config = {"skip_steps": skip_policy} if skip_policy is not None else None if command_input.from_step is not None and command_input.to_step is not None: try: - from chipcompiler.rtl2gds import build_flow_range + from chipcompiler.rtl2gds import build_flow_range, resolve_skip_steps - build_flow_range(command_input.from_step, command_input.to_step) + skip = resolve_skip_steps( + {"skip_steps": skip_policy} if skip_policy is not None else None + ) + build_flow_range(command_input.from_step, command_input.to_step, skip=skip) except ValueError as exc: return error("flow_range_invalid", reason=str(exc)) flow_config = { "start_step": command_input.from_step, "end_step": command_input.to_step, } + if skip_policy is not None: + flow_config["skip_steps"] = skip_policy cli_overrides = {} raw_sets = command_input.param_set @@ -503,7 +525,7 @@ def error(kind: str, **fields) -> CommandResult: # probe set from the selected chain, so a missing tool fails fast # instead of surfacing mid-creation. if fresh_target: - if flow_config is not None: + if flow_config_selects_steps(flow_config): preflight = _preflight_flow_range(flow_config, project) elif effective_preset: preflight = _preflight_environment(effective_preset, project) diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index 4ec9e3c7..e432978c 100644 --- a/chipcompiler/cli/project/config.py +++ b/chipcompiler/cli/project/config.py @@ -33,6 +33,10 @@ class ProjectConfig: pdk_overrides: dict[str, object] = field(default_factory=dict) flow_preset: str = "" + # Declared [flow] skip_steps spelling; None when the key is absent (the + # code default then applies). The manifest's per-workspace value wins + # over this one for this key only. + flow_skip_steps: list[str] | None = None config_path: str = "" project_dir: str = "" @@ -99,6 +103,10 @@ def _str(val, default=""): pdk_overrides = {} if not isinstance(pdk_overrides_raw, dict) else pdk_overrides_raw raw_run = flow.get("run") + # Raw declared value (even an invalid one): presence is tracked through + # _explicit_keys, and validate_project_config surfaces invalid shapes + # instead of silently dropping them. + skip_steps = flow.get("skip_steps") cfg = ProjectConfig( design_name=_str(design.get("name", "")), @@ -115,6 +123,7 @@ def _str(val, default=""): pdk_root=_str(pdk.get("root", "")), pdk_overrides=pdk_overrides, flow_preset=_str(flow.get("preset", "")), + flow_skip_steps=skip_steps, config_path=config_path, project_dir=project_dir, ) @@ -231,6 +240,14 @@ def validate_project_config(cfg: ProjectConfig) -> list[str]: elif cfg.flow_preset not in _supported_flow_presets(): errors.append(f"unsupported flow.preset: {cfg.flow_preset}") + if "flow.skip_steps" in cfg._explicit_keys: + from chipcompiler.rtl2gds import resolve_skip_steps + + try: + resolve_skip_steps({"skip_steps": cfg.flow_skip_steps}) + except ValueError as exc: + errors.append(str(exc)) + errors.extend(cfg._flow_config_errors) return errors diff --git a/chipcompiler/cli/project/effective_config.py b/chipcompiler/cli/project/effective_config.py index 8e19234e..b77c8de1 100644 --- a/chipcompiler/cli/project/effective_config.py +++ b/chipcompiler/cli/project/effective_config.py @@ -37,6 +37,43 @@ def _resolve_entry(manifest, run_name: str | None): return active[0] if len(active) == 1 else None +def declared_skip_steps(entry, cfg) -> list | None: + """The declared skip policy: project.json entry wins over ecc.toml. + + The only key with this precedence direction — ecc.toml keeps winning + for every other field. None means neither surface declared a policy + (the code default applies downstream); [] is an explicit run-everything. + """ + if entry is not None and getattr(entry, "skip_steps", None) is not None: + return list(entry.skip_steps) + if "flow.skip_steps" in getattr(cfg, "_explicit_keys", frozenset()): + return list(getattr(cfg, "flow_skip_steps", None) or []) + return None + + +def _attach_skip_steps(flow_config: dict | None, skip_steps: list | None) -> dict | None: + """Carry a declared skip policy on the flow config (policy-only when + the config selects no steps).""" + if skip_steps is None: + return flow_config + if flow_config is None: + return {"skip_steps": skip_steps} + flow_config = dict(flow_config) + flow_config["skip_steps"] = skip_steps + return flow_config + + +def flow_config_selects_steps(flow_config) -> bool: + """Whether a flow config names steps (a range or explicit selection). + + A policy-only config (just ``skip_steps``) selects nothing: it must + not satisfy a flow-target requirement nor trigger range preflight. + """ + if not isinstance(flow_config, dict): + return False + return bool(flow_config.get("start_step")) or bool(flow_config.get("steps")) + + def resolve_effective_config( ctx, run_name: str | None, cfg: "ProjectConfig | None" ) -> "CommandResult | tuple[ProjectConfig, dict | None, list[dict]]": @@ -73,6 +110,9 @@ def resolve_effective_config( if "flow.preset" not in cfg._explicit_keys: flow_config = {"start_step": entry.start_step, "end_step": entry.end_step} + # skip_steps is the one key where the manifest layer outranks ecc.toml. + flow_config = _attach_skip_steps(flow_config, declared_skip_steps(entry, cfg)) + warnings = [] diverging = layer_divergences(cfg, assembled, entry) if diverging: @@ -243,7 +283,7 @@ def validate_effective(ctx, cfg, *, fresh: bool, flow_config, cli_overrides=None sources = cfg.design_rtl if len(cfg.design_rtl) > 1 else cfg.design_rtl[1:] for entry in sources: errors.extend(_validate_rtl_source(cfg.project_dir, entry)) - if fresh and not cfg.flow_preset and flow_config is None: + if fresh and not cfg.flow_preset and not flow_config_selects_steps(flow_config): errors.append( "no flow target: set flow.preset in ecc.toml or declare the workspace's " "start/end range in project.json" diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 93463ef2..94d5c1c1 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -59,6 +59,9 @@ class MigrationEntry: status: str start_step: str end_step: str + # Declared [flow] skip_steps carried from the workspace's params.toml; + # None when the workspace declared no policy. + skip_steps: tuple[str, ...] | None = None # Plan-time lstat identity of the confirmed source: a substituted # real directory fails the move-time check, not just a symlink. source_dev: int = 0 @@ -174,6 +177,38 @@ def _is_contiguous_flow(names: list[str]) -> bool: return False +@deprecated( + "legacy runs/ -> manifest layout migration machinery; slated for removal " + "after the transition period", + category=None, +) +def _persisted_skip_steps(run_dir: str) -> tuple[str, ...] | None: + """The workspace's declared ``[flow] skip_steps``; None when absent. + + Read raw like the ledger above: the value was validated when written, + and a hand-broken one degrades to an undeclared policy (the default) + instead of poisoning the migrated manifest against ever loading. + """ + import tomllib + + config_path = os.path.join(run_dir, "home", "params.toml") + try: + with open(config_path, "rb") as f: + data = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError): + return None + flow = data.get("flow") + if not isinstance(flow, dict) or "skip_steps" not in flow: + return None + from chipcompiler.rtl2gds import resolve_skip_steps + + try: + resolve_skip_steps({"skip_steps": flow["skip_steps"]}) + except ValueError: + return None + return tuple(flow["skip_steps"]) + + @deprecated( "legacy runs/ -> manifest layout migration machinery; slated for removal " "after the transition period", @@ -267,6 +302,7 @@ def plan_migration(project_dir: str) -> MigrationPlan: status=_flow_status(steps), start_step=CANONICAL_TO_DISPLAY.get(names[0], "Synth") if names else "Synth", end_step=CANONICAL_TO_DISPLAY.get(names[-1], "Harden") if names else "Harden", + skip_steps=_persisted_skip_steps(source), source_dev=source_stat.st_dev, source_ino=source_stat.st_ino, ) @@ -310,6 +346,7 @@ def _workspace_entries( end_step=entry.end_step, status=entry.status, now=now, + skip_steps=list(entry.skip_steps) if entry.skip_steps is not None else None, ) for entry in entries ) @@ -344,6 +381,7 @@ def build_migration_preview(project_dir: str, cfg) -> MigrationPreview: start_step=first.start_step, end_step=first.end_step, status=first.status, + skip_steps=list(first.skip_steps) if first.skip_steps is not None else None, ) document["workspaces"].extend( _workspace_entries( diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 8a041f61..48c1f584 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -118,6 +118,17 @@ def _workspace_failed_result(run_name: str, run_dir: str, reason: str | None) -> return CommandResult.err([record]) +def _flow_config_selects_steps(flow_config) -> bool: + """Whether a creation flow config names steps (range or selection). + + A policy-only config (just ``skip_steps``) selects nothing and must not + mask a preset target. + """ + if not isinstance(flow_config, dict): + return False + return bool(flow_config.get("start_step")) or bool(flow_config.get("steps")) + + def _fresh_entry_step_name(cfg, flow_config) -> str | None: """The canonical first step a fresh workspace target will execute. @@ -440,11 +451,15 @@ def failed_workspace(reason: str | None) -> CommandResult: with open(provenance_path, "w") as _f: json.dump(cli_overrides, _f) - if flow_config is None: - # CLI-born workspaces persist the named prefix chain as their target. + if not _flow_config_selects_steps(flow_config): + # CLI-born workspaces persist the named preset chain as + # their target; a declared skip policy rides along. workspace_parameters = getattr(workspace, "parameters", None) if workspace_parameters is not None: - workspace_parameters.data["_flow"] = {"preset": cfg.flow_preset} + flow_section = {"preset": cfg.flow_preset} + if isinstance(flow_config, dict) and "skip_steps" in flow_config: + flow_section["skip_steps"] = flow_config["skip_steps"] + workspace_parameters.data["_flow"] = flow_section if not save_parameter(workspace_parameters): return failed_workspace("failed to persist the flow target in params.toml") except Exception as exc: diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index 941b8b38..f0f52834 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -12,6 +12,7 @@ [design] name / top / clock_port / frequency_mhz [pdk] name / root (absolute) / config (workspace-relative) [flow] preset = "rtl2gds" OR start = "...", end = "..." + skip_steps = [...] (optional, normalized) [params] flat snake_case parameters; nested dicts map to subtables """ @@ -102,12 +103,15 @@ def parameters_have_chip_identity(data: object) -> bool: return False -def validate_flow_config(flow: object) -> dict[str, str]: - """Validate a ``[flow]`` section; return it as a plain string dict. +def validate_flow_config(flow: object) -> dict: + """Validate a ``[flow]`` section; return it as a plain dict. Raises WorkspaceFlowTargetError on any rule violation: ``preset`` mixed with ``start``/``end``, only one of ``start``/``end``, unknown step - names, or ``start`` positioned after ``end`` in the canonical chain. + names, ``start`` positioned after ``end`` in the canonical chain, or an + invalid ``skip_steps`` list. ``skip_steps`` is stored normalized + (canonical step values in canonical chain order); an explicit empty + list round-trips as ``[]`` and an absent key stays absent. """ if flow is None: return {} @@ -121,10 +125,20 @@ def validate_flow_config(flow: object) -> dict[str, str]: raise WorkspaceFlowTargetError("[flow] preset cannot be combined with start/end") if (start is None) != (end is None): raise WorkspaceFlowTargetError("[flow] start and end must be set together") - if preset is None and start is None: + if preset is None and start is None and "skip_steps" not in section: return {} - result: dict[str, str] = {} + result: dict = {} + if "skip_steps" in section: + from chipcompiler.rtl2gds import resolve_skip_steps + + try: + result["skip_steps"] = list(resolve_skip_steps({"skip_steps": section["skip_steps"]})) + except ValueError as exc: + raise WorkspaceFlowTargetError(f"[flow] {exc}") from None + if preset is None and start is None: + # A policy-only section (skip_steps without a flow target). + return result if preset is not None: if not isinstance(preset, str) or not preset.strip(): raise WorkspaceFlowTargetError(f"[flow] preset must be a non-empty string: {preset!r}") @@ -148,7 +162,8 @@ def validate_flow_config(flow: object) -> dict[str, str]: raise WorkspaceFlowTargetError( f"[flow] start {normalized['start']!r} is after end {normalized['end']!r}" ) - return normalized + result.update(normalized) + return result def canonical_flow_chain() -> list[str]: @@ -183,10 +198,10 @@ def flow_range_for_preset(preset: str) -> tuple[str, str]: def flow_range_of(flow: dict) -> tuple[str, str] | None: """(start, end) canonical names for a validated [flow] section.""" flow = validate_flow_config(flow) - if not flow: - return None if "preset" in flow: return flow_range_for_preset(flow["preset"]) + if "start" not in flow: + return None return (flow["start"], flow["end"]) @@ -204,11 +219,13 @@ def flow_steps_in_range(start: str, end: str) -> list[str]: raise WorkspaceFlowTargetError(f"flow range outside the canonical chain: {exc}") from exc -def flow_section_from_flow_config(flow_config: dict | None) -> dict[str, str]: +def flow_section_from_flow_config(flow_config: dict | None) -> dict: """Derive the [flow] section (start/end canonical form) from a flow_config. Uses the same selection resolution as the flow.json seeding, so both - stores always describe the same contiguous range. Returns {} when the + stores always describe the same contiguous range. A declared + ``skip_steps`` policy rides along (normalized); an undeclared one + stays absent so the code default keeps applying. Returns {} when the flow_config does not select steps. """ if not isinstance(flow_config, dict) or not flow_config: @@ -221,7 +238,10 @@ def flow_section_from_flow_config(flow_config: dict | None) -> dict[str, str]: return {} # Names are already canonical here; validate to keep the contract explicit. - return validate_flow_config({"start": selected[0], "end": selected[-1]}) + section = {"start": selected[0], "end": selected[-1]} + if "skip_steps" in flow_config: + section["skip_steps"] = flow_config["skip_steps"] + return validate_flow_config(section) def _split_payload(data: dict) -> dict[str, Any]: diff --git a/chipcompiler/project/manifest.py b/chipcompiler/project/manifest.py index 1df09e9b..b66ba308 100644 --- a/chipcompiler/project/manifest.py +++ b/chipcompiler/project/manifest.py @@ -96,6 +96,10 @@ class ManifestWorkspace: end_step: str status: str parameter_patch: dict = field(default_factory=dict) + # Declared workspaces[].skip_steps spelling (aliases/duplicates kept); + # None when the key is absent, () for an explicit empty list. Wins over + # ecc.toml [flow] skip_steps for this key only. + skip_steps: tuple[str, ...] | None = None raw: dict = field(default_factory=dict) @@ -131,6 +135,24 @@ def _record(value: Any) -> dict: return value if isinstance(value, dict) else {} +def _workspace_skip_steps(source: dict, index: int) -> tuple[str, ...] | None: + """Validated workspaces[].skip_steps; None when the key is absent. + + The declared spelling is kept verbatim (one normalizer exists, in the + skip resolver); only its validity is checked here so an invalid value + fails the whole manifest load before any registration or write. + """ + if "skip_steps" not in source: + return None + from chipcompiler.rtl2gds import resolve_skip_steps + + try: + resolve_skip_steps({"skip_steps": source["skip_steps"]}) + except ValueError as exc: + raise ManifestError(f"workspaces[{index}] {exc}") from None + return tuple(source["skip_steps"]) + + def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace: source = _record(value) workspace_id = _optional_str(source.get("workspace_id")) @@ -176,6 +198,7 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani end_step=end_step, status=status, parameter_patch=_record(source.get("parameter_patch")), + skip_steps=_workspace_skip_steps(source, index), raw=dict(source), ) diff --git a/chipcompiler/project/manifest_write.py b/chipcompiler/project/manifest_write.py index 71714bb1..b386db11 100644 --- a/chipcompiler/project/manifest_write.py +++ b/chipcompiler/project/manifest_write.py @@ -61,13 +61,16 @@ def manifest_workspace_entry( end_step: str, status: str, now: str, + skip_steps: list[str] | None = None, ) -> dict: """One complete schema-v1 workspaces[] entry, every field materialized. The single builder for generated manifests and migration previews, so the previewed entry and the applied entry are the same object shape. + ``skip_steps`` is materialized only when the workspace carries a + declared policy (an explicit empty list stays []). """ - return { + entry = { "workspace_id": workspace_id, "name": name, "workspace_path": workspace_path, @@ -82,6 +85,9 @@ def manifest_workspace_entry( "metrics_summary": {}, "step_metrics": {}, } + if skip_steps is not None: + entry["skip_steps"] = list(skip_steps) + return entry def build_manifest_document( @@ -94,6 +100,7 @@ def build_manifest_document( start_step: str, end_step: str, status: str = "running", + skip_steps: list[str] | None = None, ) -> dict: """Assemble a schema-v1 manifest for a virgin project's first run.""" now = _now_iso() @@ -112,6 +119,7 @@ def build_manifest_document( end_step=end_step, status=status, now=now, + skip_steps=skip_steps, ) ] document["qor_baseline"] = { diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index 32874ef8..98453753 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -450,6 +450,68 @@ def test_missing_flow_json_migrates_with_not_started_defaults( (workspace,) = _manifest(project_dir)["workspaces"] assert workspace["status"] == "not_started" + def test_persisted_skip_policy_carries_into_the_manifest_entry( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + from chipcompiler.data.workspace_config import save_workspace_config + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + # A declared policy (here: timing optimization) on the existing payload. + from chipcompiler.data.workspace_config import load_workspace_config + + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config( + run_dir, + payload, + {"start": "Synthesis", "end": "postFloorplan", "skip_steps": ["TimingOpt"]}, + ) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc == 0 + (workspace,) = _manifest(project_dir)["workspaces"] + assert workspace["skip_steps"] == ["TimingOpt"] + + def test_undeclared_and_empty_skip_policies_stay_distinct_after_migration( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + from chipcompiler.data.workspace_config import ( + load_workspace_config, + save_workspace_config, + ) + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + absent_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + empty_dir = create_legacy_workspace(project_dir, pdk_root, "exp2", ["Success", "Success"]) + for run_dir, section in ( + (empty_dir, {"start": "Synthesis", "end": "postFloorplan", "skip_steps": []}), + (absent_dir, {"start": "Synthesis", "end": "postFloorplan"}), + ): + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config(run_dir, payload, section) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc == 0 + entries = {entry["workspace_id"]: entry for entry in _manifest(project_dir)["workspaces"]} + assert "skip_steps" not in entries["exp1"] + assert entries["exp2"]["skip_steps"] == [] + def test_non_object_flow_json_is_blocked( self, tmp_path, diff --git a/test/cli/project/test_skip_steps_config.py b/test/cli/project/test_skip_steps_config.py new file mode 100644 index 00000000..e3dfca25 --- /dev/null +++ b/test/cli/project/test_skip_steps_config.py @@ -0,0 +1,185 @@ +"""skip_steps configuration surface: project.json, ecc.toml, precedence. + +One home for the config-layer tests of the skip policy: manifest parsing +and raw round-trip, ecc.toml parsing and validation, the skip-specific +precedence (project.json wins for this key only), and the materialized +default in generated projects. +""" + +import json + +import pytest + +from chipcompiler.cli.project.config import load_project_config, validate_project_config +from chipcompiler.cli.project.effective_config import declared_skip_steps +from chipcompiler.cli.project.manifest import ManifestError, load_manifest +from chipcompiler.cli.project.manifest_write import write_back_workspace_status + + +def _write_manifest(project_dir, workspaces): + document = { + "schema_version": 1, + "design_name": "gcd", + "root_path": str(project_dir), + "workspaces": workspaces, + } + (project_dir / "project.json").write_text(json.dumps(document)) + + +def _workspace(project_dir, **extra): + entry = { + "workspace_id": "ws_0001", + "workspace_path": str(project_dir / "ws_0001"), + "status": "not_started", + } + entry.update(extra) + return entry + + +class TestManifestSkipSteps: + def test_absent_key_loads_as_none(self, tmp_path): + _write_manifest(tmp_path, [_workspace(tmp_path)]) + + (entry,) = load_manifest(str(tmp_path)).workspaces + + assert entry.skip_steps is None + + def test_declared_spelling_is_kept_verbatim(self, tmp_path): + _write_manifest(tmp_path, [_workspace(tmp_path, skip_steps=["LEC", "lec", "postlec"])]) + + (entry,) = load_manifest(str(tmp_path)).workspaces + + # One normalizer exists (the resolver); the manifest stores the + # declared spelling and only checks its validity. + assert entry.skip_steps == ("LEC", "lec", "postlec") + + def test_explicit_empty_list_is_distinct_from_absent(self, tmp_path): + _write_manifest(tmp_path, [_workspace(tmp_path, skip_steps=[])]) + + (entry,) = load_manifest(str(tmp_path)).workspaces + + assert entry.skip_steps == () + + @pytest.mark.parametrize( + "value", + [ + "lec", + None, + [1], + ["route"], + ["bogus"], + ], + ) + def test_invalid_value_fails_the_whole_manifest_load(self, tmp_path, value): + _write_manifest(tmp_path, [_workspace(tmp_path, skip_steps=value)]) + + with pytest.raises(ManifestError, match=r"workspaces\[0\].*skip_steps"): + load_manifest(str(tmp_path)) + + def test_status_write_back_preserves_raw_spelling(self, tmp_path): + _write_manifest(tmp_path, [_workspace(tmp_path, skip_steps=["LEC", "lec"])]) + + assert write_back_workspace_status(str(tmp_path), "ws_0001", "running") + + document = json.loads((tmp_path / "project.json").read_text()) + assert document["workspaces"][0]["skip_steps"] == ["LEC", "lec"] + assert document["workspaces"][0]["status"] == "running" + + +class TestEccTomlSkipSteps: + def _config(self, tmp_path, flow_extra): + (tmp_path / "ecc.toml").write_text( + "[design]\n" + 'name = "gcd"\n' + 'top = "gcd"\n' + 'rtl = ["rtl/gcd.v"]\n' + 'clock_port = "clk"\n' + "frequency_mhz = 100.0\n" + "\n[flow]\n" + 'preset = "rtl2gds"\n' + flow_extra + ) + return load_project_config(str(tmp_path / "ecc.toml")) + + def test_absent_key_parses_as_none(self, tmp_path): + cfg = self._config(tmp_path, "") + + assert cfg.flow_skip_steps is None + + def test_declared_list_is_kept_verbatim(self, tmp_path): + cfg = self._config(tmp_path, 'skip_steps = ["TimingOpt", "lec"]\n') + + assert cfg.flow_skip_steps == ["TimingOpt", "lec"] + + @pytest.mark.parametrize( + "flow_extra", + [ + 'skip_steps = "lec"\n', + "skip_steps = 3\n", + "skip_steps = [1]\n", + 'skip_steps = ["route"]\n', + ], + ) + def test_invalid_value_is_a_config_error(self, tmp_path, flow_extra): + cfg = self._config(tmp_path, flow_extra) + + assert any("skip_steps" in err for err in validate_project_config(cfg)) + + +class TestSkipStepsPrecedence: + def _entry_and_cfg(self, tmp_path, manifest_skip=None, toml_skip=None): + _write_manifest( + tmp_path, + [ + _workspace(tmp_path) + if manifest_skip == "absent" + else _workspace( + tmp_path, + skip_steps=[] if manifest_skip == "empty" else manifest_skip, + ) + ], + ) + entry = load_manifest(str(tmp_path)).workspaces[0] + toml_flow = "" if toml_skip == "absent" else f"skip_steps = {toml_skip!r}\n" + (tmp_path / "ecc.toml").write_text( + "[design]\n" + 'name = "gcd"\n' + 'top = "gcd"\n' + 'rtl = ["rtl/gcd.v"]\n' + 'clock_port = "clk"\n' + "frequency_mhz = 100.0\n" + "\n[flow]\n" + 'preset = "rtl2gds"\n' + toml_flow.replace("'", '"') + ) + cfg = load_project_config(str(tmp_path / "ecc.toml")) + return entry, cfg + + def test_project_json_wins_over_ecc_toml(self, tmp_path): + entry, cfg = self._entry_and_cfg(tmp_path, manifest_skip=["TimingOpt"], toml_skip=["lec"]) + + assert declared_skip_steps(entry, cfg) == ["TimingOpt"] + + def test_project_json_explicit_empty_wins_over_ecc_toml(self, tmp_path): + entry, cfg = self._entry_and_cfg(tmp_path, manifest_skip="empty", toml_skip=["lec"]) + + assert declared_skip_steps(entry, cfg) == [] + + def test_ecc_toml_applies_when_project_json_lacks_the_key(self, tmp_path): + entry, cfg = self._entry_and_cfg(tmp_path, manifest_skip="absent", toml_skip=["lec"]) + + assert declared_skip_steps(entry, cfg) == ["lec"] + + def test_code_default_when_both_surfaces_are_absent(self, tmp_path): + entry, cfg = self._entry_and_cfg(tmp_path, manifest_skip="absent", toml_skip="absent") + + assert declared_skip_steps(entry, cfg) is None + + +def test_init_materializes_the_default_skip_into_generated_ecc_toml(tmp_path): + from chipcompiler.cli import main as cli_main + + rc = cli_main.run(["init", str(tmp_path / "gcd")]) + + assert rc == 0 + toml = (tmp_path / "gcd" / "ecc.toml").read_text() + assert 'skip_steps = ["lec"]' in toml + assert "LEC is skipped by default; clear the list to enable it." in toml diff --git a/test/data/test_workspace_config.py b/test/data/test_workspace_config.py index 790f5663..66f86d59 100644 --- a/test/data/test_workspace_config.py +++ b/test/data/test_workspace_config.py @@ -271,6 +271,65 @@ def test_flow_validation_rejects_unknown_preset(): validate_flow_config({"preset": "does_not_exist"}) +def test_flow_section_skip_steps_normalize(): + assert validate_flow_config({"skip_steps": ["LEC", "postlec"]}) == { + "skip_steps": ["lec", "postRouteLec"] + } + # Idempotent on already-normalized input. + assert validate_flow_config({"skip_steps": ["lec", "postRouteLec"]}) == { + "skip_steps": ["lec", "postRouteLec"] + } + + +def test_flow_section_skip_steps_three_states_round_trip(tmp_path): + from chipcompiler.data.workspace_config import load_workspace_config + + payload = {"design": "gcd", "top_module": "gcd", "clock": "clk"} + + def _round_trip(section): + assert save_workspace_config(tmp_path, payload, section) + return load_workspace_config(tmp_path)["_flow"] + + assert _round_trip({"start": "Synthesis", "end": "Harden"}) == { + "start": "Synthesis", + "end": "Harden", + } + assert _round_trip({"start": "Synthesis", "end": "Harden", "skip_steps": []}) == { + "start": "Synthesis", + "end": "Harden", + "skip_steps": [], + } + assert _round_trip({"preset": "rtl2gds", "skip_steps": ["LEC"]}) == { + "preset": "rtl2gds", + "skip_steps": ["lec"], + } + + +def test_flow_section_policy_only_skip_steps_is_valid(): + assert validate_flow_config({"skip_steps": ["lec"]}) == {"skip_steps": ["lec"]} + assert flow_range_of({"skip_steps": ["lec"]}) is None + + +def test_flow_section_rejects_invalid_skip_steps(): + with pytest.raises(WorkspaceFlowTargetError, match="skip_steps"): + validate_flow_config({"skip_steps": "lec"}) + with pytest.raises(WorkspaceFlowTargetError, match="cannot be skipped"): + validate_flow_config({"skip_steps": ["route"]}) + + +def test_flow_section_from_flow_config_carries_declared_skip(): + section = flow_section_from_flow_config( + {"start_step": "Place", "end_step": "Route", "skip_steps": ["TimingOpt"]} + ) + assert section == {"start": "place", "end": "route", "skip_steps": ["Timing optimization"]} + + # An undeclared policy stays absent; the code default keeps applying. + assert flow_section_from_flow_config({"start_step": "Place", "end_step": "Route"}) == { + "start": "place", + "end": "route", + } + + def test_save_replace_and_cleanup_failure_returns_false( tmp_path, monkeypatch, stubborn_candidate_unlink ): From 981c7e2c2cfd9e6cc2cb5aaee2e5628b39c3b533 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 22:41:15 +0800 Subject: [PATCH 05/19] feat(data): validate skip policy before any creation mutation data.create_workspace resolves the skip policy before touching the filesystem, so an invalid policy is a clean configuration error instead of a partial workspace tree. The runtime API validates it before materializing sidecar artifacts (temp filelist, inline PDK). Creation path equivalence is pinned by a test: preset, ranged, and sidecar paths build identical ledgers for one policy, and a policy-only flow config never yields a ledger --- chipcompiler/data/workspace/__init__.py | 6 +++++ chipcompiler/runtime/workspace_api.py | 10 +++++++ test/data/test_workspace.py | 22 +++++++++++++++ test/rtl2gds/test_builder.py | 36 +++++++++++++++++++++++++ test/runtime/test_workspace_api.py | 19 +++++++++++++ 5 files changed, 93 insertions(+) diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 2419a3ec..fefb4f5e 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -1057,6 +1057,12 @@ def create_workspace( - input_filelist takes priority over origin_verilog for synthesis when both exist - All input files are copied to workspace/origin/ directory """ + # The skip policy is validated before anything on disk is touched: an + # invalid policy is a configuration error, never a partial workspace. + from chipcompiler.rtl2gds import resolve_skip_steps + + resolve_skip_steps(flow_config) + # create workspace directory import shutil diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index af5b459e..930a5e61 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -129,6 +129,16 @@ def _create_legacy_workspace(self, request: WorkspaceCreateRequest) -> dict: if not request.directory: raise RuntimeApiError("invalid_request", "missing required field: directory") + import chipcompiler.rtl2gds as rtl2gds_api + + # The skip policy is validated before any sidecar artifact (temp + # filelist, inline PDK) is materialized: invalid input must not + # reach workspace creation or leave temporaries behind. + try: + rtl2gds_api.resolve_skip_steps(request.flow_config) + except ValueError as exc: + raise RuntimeApiError("config_error", f"invalid skip_steps: {exc}") from exc + temp_filelist_dir = None input_filelist = request.filelist if not input_filelist: diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 01d07ba2..ceb6ed4f 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -221,6 +221,28 @@ def test_create_workspace_persists_dynamic_flow_steps( assert all(step["peak memory (mb)"] == 0 for step in flow_data["steps"]) +def test_create_workspace_rejects_invalid_skip_steps_before_any_mutation( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + with pytest.raises(ValueError, match="skip_steps"): + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "Harden", "skip_steps": "lec"}, + ) + + assert not workspace_dir.exists() + + def test_create_workspace_copies_external_lec_and_sta_inputs( tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters ): diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index 90ad9719..2cd8c35b 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -179,3 +179,39 @@ def test_filter_flow_steps_removes_entries_without_reordering(): assert builder_module.filter_flow_steps(steps, ()) == steps assert builder_module.filter_flow_steps(steps, ("lec",)) == steps[:1] + + +def test_all_creation_paths_build_the_same_ledger_for_one_policy(): + from chipcompiler.data.workspace import build_dynamic_flow_data + + policy = {"start_step": "Synthesis", "end_step": "Harden", "skip_steps": ["lec"]} + + # Sidecar/direct flow_config path: the dynamic ledger. + sidecar = [step["name"] for step in build_dynamic_flow_data(policy)["steps"]] + + # CLI ranged path: build_flow_range with the resolved policy. + ranged = [ + step.value + for step, _tool, _state in builder_module.build_flow_range( + "Synthesis", "Harden", skip=builder_module.resolve_skip_steps(policy) + ) + ] + + # CLI preset path: the no-arg builder output filtered post-call. + preset = [ + step.value + for step, _tool, _state in builder_module.filter_flow_steps( + builder_module.build_rtl2gds_flow(), + builder_module.resolve_skip_steps(policy), + ) + ] + + assert sidecar == ranged == preset + assert "lec" not in sidecar + + +def test_policy_only_flow_config_never_yields_a_ledger(): + from chipcompiler.data.workspace import build_dynamic_flow_data + + assert build_dynamic_flow_data({"skip_steps": ["lec"]}) == {} + assert build_dynamic_flow_data({"skip_steps": []}) == {} diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index 562637cf..dcac8345 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -299,6 +299,25 @@ def test_create_workspace_forwards_dynamic_flow_config(monkeypatch, tmp_path): assert capture["create_kwargs"]["flow_config"] == flow_config +def test_create_workspace_rejects_invalid_skip_steps_before_sidecars(monkeypatch, tmp_path): + capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + + with pytest.raises(RuntimeApiError, match="skip_steps") as exc_info: + api.create_workspace( + WorkspaceCreateRequest( + directory=str(ws), + rtl_list=["a.v"], + flow_config={"skip_steps": "lec"}, + ) + ) + + assert exc_info.value.code == "config_error" + # The failure happened before any materialization: no workspace creation + # call, and the temp filelist was never written. + assert capture["create_kwargs"] is None + + def test_create_workspace_writes_rtl_list_filelist_outside_workspace( monkeypatch, tmp_path, From 268f9a51bbfcdda69c1658351a64919a1ca9039d Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 22:52:50 +0800 Subject: [PATCH 06/19] feat(engine): policy-driven reconcile targets and preset conflict error Reconcile targets now apply the [flow] section's skip policy, so post-revert ledgers reconcile naturally under the default policy and the legacy_missing_synthesis_lec hack is deleted. A ledger holding steps the effective policy skips stays runnable: those entries are inert for comparison and are never removed or re-inserted (append skips entries the ledger already holds). Selecting the synthesis_lec preset while the effective policy skips lec is a creation-time configuration error naming skip_steps = [] as the fix; resume/rerun on existing ledgers is unaffected --- chipcompiler/cli/command_handlers/project.py | 30 ++++++ chipcompiler/engine/reconcile.py | 97 ++++++++++++-------- chipcompiler/rtl2gds/builder.py | 5 +- test/cli/commands/test_run.py | 53 +++++++++++ test/engine/test_reconcile.py | 70 ++++++++++++++ 5 files changed, 214 insertions(+), 41 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 71c469b1..b7b7192f 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -502,6 +502,27 @@ def error(kind: str, **fields) -> CommandResult: ] ) + # A creation-time (fresh/overwrite only) configuration conflict: the + # synthesis_lec preset exists to run the LEC the effective policy skips. + # Existing ledgers are never re-filtered, so resume/rerun is unaffected. + if ( + fresh_target + and effective_preset == "synthesis_lec" + and "lec" in resolve_skip_steps_for_flow_config(flow_config) + ): + return CommandResult.err( + [ + { + "kind": "error", + "error": "config_error", + "reason": ( + "the synthesis_lec preset conflicts with the effective skip_steps " + "policy (lec is skipped); set skip_steps = [] to enable it" + ), + } + ] + ) + protected = (project_dir, os.path.join(project_dir, "runs")) spelled = {os.path.normpath(p) for p in protected} canonical = {os.path.realpath(p) for p in protected} @@ -563,6 +584,15 @@ def _config_error_code(reason: str) -> str: return "config_error" +def resolve_skip_steps_for_flow_config(flow_config) -> tuple[str, ...]: + """The effective skip policy of a creation flow config (default when none).""" + from chipcompiler.rtl2gds import resolve_skip_steps + + return resolve_skip_steps( + flow_config if isinstance(flow_config, dict) and "skip_steps" in flow_config else None + ) + + def _run_workspace(command_input: RunInput, ctx: CommandContext) -> CommandResult: def error(kind: str, **fields) -> CommandResult: return CommandResult.err([{"kind": "error", "error": kind, **fields}]) diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index f0e29dec..c531aa62 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -81,21 +81,41 @@ def compare_flows(persisted: list[tuple[str, str]], target: list[tuple[str, str] return "divergent" -def _is_legacy_missing_synthesis_lec( - persisted: list[tuple[str, str]], target: list[tuple[str, str]] -) -> bool: - """Recognize pre-synthesis-LEC ledgers as upgradeable flow prefixes.""" - if not any(name == "lec" and tool == "yosys_lec" for name, tool in target): - return False - target_without_lec = [entry for entry in target if entry != ("lec", "yosys_lec")] - return persisted == target_without_lec or ( - len(persisted) < len(target_without_lec) - and target_without_lec[: len(persisted)] == persisted - ) +def _relation_with_skipped_steps( + persisted: list[tuple[str, str]], target: list[tuple[str, str]], skip: tuple[str, ...] +) -> str: + """Compatibility relation treating policy-skipped ledger steps as inert. + + A ledger written under a wider policy (e.g. with the synthesis LEC + enabled) stays runnable when the effective policy excludes those + steps: the excluded entries are ignored for comparison and never + removed from the ledger. Returns "" when the ledger is not + compatible even after ignoring skipped steps. + """ + kept = [entry for entry in persisted if entry[0] not in set(skip)] + if kept == target: + return "equal" + if len(kept) < len(target) and target[: len(kept)] == kept: + return "proper_prefix" + if len(target) < len(kept) and kept[: len(target)] == target: + return "target_prefix" + return "" + + +def _resolved_skip_steps(flow_section: dict) -> tuple[str, ...]: + """The skip policy a [flow] section carries (default when undeclared).""" + from chipcompiler.rtl2gds import resolve_skip_steps + + return resolve_skip_steps(flow_section) def _target_entries(flow_section: dict) -> list[tuple[str, str]]: - """(name, tool) entries for a [flow] section, over the canonical chain.""" + """(name, tool) entries for a [flow] section, over the canonical chain. + + The section's skip policy (its declared list, or the code default when + undeclared) is applied to the chain, so the target never contains steps + the workspace excludes. + """ from chipcompiler.data.workspace import _canonical_rtl2gds_flow_entries from chipcompiler.data.workspace_config import flow_range_of @@ -105,7 +125,14 @@ def _target_entries(flow_section: dict) -> list[tuple[str, str]]: start, end = flow_range chain = _canonical_rtl2gds_flow_entries() names = [name for name, _tool, _state in chain] - return [(name, tool) for name, tool, _state in chain[names.index(start) : names.index(end) + 1]] + entries = [ + (name, tool) for name, tool, _state in chain[names.index(start) : names.index(end) + 1] + ] + skip = _resolved_skip_steps(flow_section) + if skip: + excluded = set(skip) + entries = [entry for entry in entries if entry[0] not in excluded] + return entries def _derive_section_from_persisted(persisted: list[tuple[str, str]]) -> dict: @@ -191,8 +218,15 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): return ReconcileResult(outcome="no_op", target=_entry_names(target)), {} relation = compare_flows(persisted, target) - if relation == "divergent" and _is_legacy_missing_synthesis_lec(persisted, target): - relation = "legacy_missing_synthesis_lec" + if relation == "divergent": + # A ledger holding steps the target's policy skips stays compatible: + # the skipped entries are inert, never removed or re-inserted. + relation = ( + _relation_with_skipped_steps( + persisted, target, _resolved_skip_steps(target_section or {}) + ) + or relation + ) if relation == "divergent": return ( ReconcileResult( @@ -208,7 +242,7 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): stale = flow_range_of(workspace_flow) != flow_range_of(target_section) else: stale = bool(target_section) - if relation in {"proper_prefix", "legacy_missing_synthesis_lec"} or stale: + if relation == "proper_prefix" or stale: context = { "flow_data": flow_data, "persisted": persisted, @@ -338,13 +372,19 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) if relation == "proper_prefix": # Append the missing suffix as Unstart, then adopt the target. + # Entries already in the ledger (e.g. steps the effective policy + # now skips) are never appended twice, so the suffix is computed + # against what the ledger actually holds. import copy from chipcompiler.data.workspace import _flow_step_template context["flow_data_original"] = copy.deepcopy(flow_data) steps = flow_data.setdefault("steps", []) - for name, tool in target[len(persisted) :]: + present = set(persisted) + for name, tool in target: + if (name, tool) in present: + continue steps.append(_flow_step_template(name, tool, "Unstart")) appended.append(name) if not json_write(workspace_dir / "home" / "flow.json", flow_data): @@ -354,29 +394,6 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) ) adopted_flow = dict(target_section) outcome = "extended" - elif relation == "legacy_missing_synthesis_lec": - # Insert the newly required synthesis-level LEC while preserving all - # existing step records and their states. - import copy - - from chipcompiler.data.workspace import _flow_step_template - - context["flow_data_original"] = copy.deepcopy(flow_data) - steps = flow_data.setdefault("steps", []) - lec_index = next(index for index, (name, _tool) in enumerate(target) if name == "lec") - lec_name, lec_tool = target[lec_index] - steps.insert(lec_index, _flow_step_template(lec_name, lec_tool, "Unstart")) - appended.append(lec_name) - for name, tool in target[len(persisted) + 1 :]: - steps.append(_flow_step_template(name, tool, "Unstart")) - appended.append(name) - if not json_write(workspace_dir / "home" / "flow.json", flow_data): - return ReconcileResult( - outcome="mismatch", - error=f"failed to insert flow step into {workspace_dir / 'home' / 'flow.json'}", - ) - adopted_flow = dict(target_section) - outcome = "extended" else: # Adopt the effective target when the persisted [flow] is stale # (crash between append and adopt, a hand-edited file, or an diff --git a/chipcompiler/rtl2gds/builder.py b/chipcompiler/rtl2gds/builder.py index 97da9464..6d28148a 100644 --- a/chipcompiler/rtl2gds/builder.py +++ b/chipcompiler/rtl2gds/builder.py @@ -39,7 +39,10 @@ def resolve_skip_steps(flow_config: dict | None) -> tuple[str, ...]: f"skip_steps names steps that cannot be skipped: {', '.join(illegal)}; " f"skippable steps: {legal}" ) - chain_names = [step.value for step, _tool, _state in build_rtl2gds_flow()] + chain_names = [ + step.value if isinstance(step, StepBaseEnum) else str(step) + for step, _tool, _state in build_rtl2gds_flow() + ] return tuple(name for name in chain_names if name in requested) diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index bda8d81d..58c642f1 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -152,6 +152,14 @@ def test_run_dispatches_builder_for_preset( ): project_dir = create_cli_project() _set_flow_preset(project_dir, preset) + if preset == "synthesis_lec": + # The preset needs the synthesis LEC the default policy skips; + # an explicit empty skip list is the only enable path. + with open(os.path.join(project_dir, "ecc.toml")) as f: + toml = f.read() + toml += "\nskip_steps = []\n" + with open(os.path.join(project_dir, "ecc.toml"), "w") as f: + f.write(toml) markers = _patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir]) @@ -159,6 +167,51 @@ def test_run_dispatches_builder_for_preset( assert rc == 0 assert flow_mocks.flow.instances[0].added_steps == markers[builder_attr] + def test_synthesis_lec_preset_conflicts_with_default_skip_policy( + self, tmp_path, capsys, create_cli_project, flow_mocks + ): + project_dir = create_cli_project() + _set_flow_preset(project_dir, "synthesis_lec") + # No skip_steps declared: the code default skips lec. + + rc = cli_main.run(["run", "--project", project_dir]) + + assert rc == 1 + assert "skip_steps = []" in capsys.readouterr().out + assert flow_mocks.capture["create_kwargs"] is None + + def test_synthesis_lec_preset_conflict_never_fires_for_existing_ledger( + self, tmp_path, create_cli_project, flow_mocks + ): + import json + + from chipcompiler.data.workspace_config import save_workspace_config + + project_dir = create_cli_project() + _set_flow_preset(project_dir, "synthesis_lec") + run_dir = os.path.join(project_dir, "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump( + { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "lec", "tool": "yosys_lec", "state": "Success"}, + ] + }, + f, + ) + assert save_workspace_config( + run_dir, + {"pdk": "ics55", "design": "gcd", "top_module": "gcd", "clock": "clk"}, + {"preset": "synthesis_lec"}, + ) + + rc = cli_main.run(["run", "--project", project_dir, "--resume"]) + + assert rc == 0 + def test_run_overwrite_rebuilds_flow_with_new_preset( self, tmp_path, monkeypatch, create_cli_project, create_flow_json, flow_mocks ): diff --git a/test/engine/test_reconcile.py b/test/engine/test_reconcile.py index fa0a430b..eaef9e1a 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -276,3 +276,73 @@ def test_adoption_failure_is_an_error_not_a_tolerated_stale_target(tmp_path, mon assert result.outcome == "mismatch" assert result.error is not None assert result.error.startswith("flow_adopt_failed") + + +class TestSkipPolicyReconcile: + """Policy-driven targets: default-skip reproduces post-#280 ledgers; + skipped ledger steps are inert, never removed or re-inserted.""" + + def test_default_policy_reproduces_ledger_without_lec(self, tmp_path): + without_lec = [(name, tool) for name, tool in RTL2GDS_STEPS if name != "lec"] + + workspace_dir = _write_workspace(tmp_path, without_lec, flow_section={"preset": "rtl2gds"}) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + assert result.outcome == "no_op" + + def test_ledger_with_lec_stays_runnable_under_default_policy(self, tmp_path): + workspace_dir = _write_workspace( + tmp_path, RTL2GDS_STEPS, flow_section={"preset": "rtl2gds"} + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + + assert result.outcome == "no_op" + # The ledger keeps its LEC entry: policy changes never remove steps. + names = [step["name"] for step in _flow_steps(workspace_dir)] + assert "lec" in names + + def test_without_lec_ledger_never_gains_lec_when_policy_enables_it(self, tmp_path): + without_lec = [(name, tool) for name, tool in RTL2GDS_STEPS if name != "lec"] + workspace_dir = _write_workspace( + tmp_path, without_lec, flow_section={"start": "Synthesis", "end": "Harden"} + ) + + result = reconcile_workspace( + workspace_dir, + {"start": "Synthesis", "end": "Harden", "skip_steps": []}, + ) + + assert result.outcome == "mismatch" + names = [step["name"] for step in _flow_steps(workspace_dir)] + assert "lec" not in names + + def test_partial_ledger_with_lec_appends_only_the_missing_suffix(self, tmp_path): + prefix = RTL2GDS_STEPS[: RTL2GDS_STEPS.index(("CTS", "ecc")) + 1] + states = ["Success"] * len(prefix) + workspace_dir = _write_workspace( + tmp_path, prefix, states=states, flow_section={"preset": "rtl2gds"} + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds"}) + + assert result.outcome == "extended" + names = [step["name"] for step in _flow_steps(workspace_dir)] + assert names == [name for name, _tool in RTL2GDS_STEPS] + assert result.appended == tuple(name for name, _tool in RTL2GDS_STEPS[len(prefix) :]) + + def test_declared_policy_extends_the_target_chain(self, tmp_path): + # A workspace declaring skip_steps=[] gets a WITH-lec target: a + # without-lec ledger extends by the suffix after the last kept step. + prefix = RTL2GDS_STEPS[:2] # Synthesis, lec + workspace_dir = _write_workspace( + tmp_path, + prefix, + flow_section={"preset": "rtl2gds", "skip_steps": []}, + ) + + result = reconcile_workspace(workspace_dir, {"preset": "rtl2gds", "skip_steps": []}) + + assert result.outcome == "extended" + names = [step["name"] for step in _flow_steps(workspace_dir)] + assert names == [name for name, _tool in RTL2GDS_STEPS] From 1cc7d8e8c3df253a8153b7433cea87d979f94725 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 22:58:41 +0800 Subject: [PATCH 07/19] feat(signoff): gate required steps on ledger membership and preflight on filtered steps The signoff collector and the ECC checklist require postRouteLec only when the workspace ledger actually contains it: a workspace created with postRouteLec skipped never false-fails, while genuine truncated-signoff failures are preserved. SIGNOFF_REQUIRED_QOR_STEPS is pinned by a test to contain no skippable step. Run preflight resolves its probe set from the policy-filtered step list, so skipping TimingOpt drops the sizer probe and the default policy never probes the synthesis LEC --- chipcompiler/cli/command_handlers/project.py | 24 ++++++++--- chipcompiler/cli/inspection/env_probe.py | 8 ++-- chipcompiler/engine/signoff/collector.py | 6 ++- chipcompiler/tools/ecc/signoff_checklist.py | 4 ++ test/cli/commands/test_doctor.py | 44 +++++++++++++++++++- test/test_signoff_package.py | 19 +++++++-- test/tools/ecc/test_signoff_checklist.py | 35 ++++++++++++++++ 7 files changed, 126 insertions(+), 14 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index b7b7192f..2058c8a2 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -191,14 +191,21 @@ def check(command_input: CheckInput, ctx: CommandContext) -> CommandResult: return CommandResult.ok(records) -def _preflight_environment(preset: str | None, project: str | None) -> CommandResult | None: +def _preflight_environment( + preset: str | None, project: str | None, flow_config: dict | None = None +) -> CommandResult | None: """Fail fast when the tools a fresh flow target needs are missing. + The probe set comes from the preset's builder output filtered by the + effective skip policy, so a skipped step's tool is never probed. + None means ready. """ from chipcompiler.cli.inspection import env_probe + from chipcompiler.rtl2gds import resolve_skip_steps - probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset)) + skip = resolve_skip_steps(flow_config) + probes = env_probe.probe_environment(env_probe.probe_components_for_preset(preset, skip=skip)) return _preflight_failures(probes, project, preset) @@ -207,13 +214,18 @@ def _preflight_flow_range(flow_config: dict, project: str | None) -> CommandResu The selected range already names its tools, so a missing tool is a preflight failure before any manifest registration or workspace - creation — not a discovery made mid-creation. + creation — not a discovery made mid-creation. The range is sliced + from the policy-filtered chain, matching ledger creation. """ from chipcompiler.cli.inspection import env_probe - from chipcompiler.rtl2gds import build_flow_range + from chipcompiler.rtl2gds import build_flow_range, resolve_skip_steps try: - steps = build_flow_range(flow_config["start_step"], flow_config["end_step"]) + steps = build_flow_range( + flow_config["start_step"], + flow_config["end_step"], + skip=resolve_skip_steps(flow_config), + ) except ValueError: # Range spellings are validated where they are declared (CLI ranges # during argument handling, manifest ranges at load time); an @@ -549,7 +561,7 @@ def error(kind: str, **fields) -> CommandResult: if flow_config_selects_steps(flow_config): preflight = _preflight_flow_range(flow_config, project) elif effective_preset: - preflight = _preflight_environment(effective_preset, project) + preflight = _preflight_environment(effective_preset, project, flow_config) else: preflight = None if preflight is not None: diff --git a/chipcompiler/cli/inspection/env_probe.py b/chipcompiler/cli/inspection/env_probe.py index fcd0ef85..9369c599 100644 --- a/chipcompiler/cli/inspection/env_probe.py +++ b/chipcompiler/cli/inspection/env_probe.py @@ -201,16 +201,18 @@ def probe_environment(components, *, cfg=None, include_slang=True) -> list[Probe } -def probe_components_for_preset(preset: str) -> tuple[str, ...]: +def probe_components_for_preset(preset: str, *, skip: tuple[str, ...] = ()) -> tuple[str, ...]: """Components a flow preset needs at minimum before it can start. The PDK is not probed here: `ecc run` already validates it through validate_project_config, and the slang check is left to the synthesis - step's existing fail-fast so preflight stays fast. + step's existing fail-fast so preflight stays fast. Skipped steps are + filtered out first, so their tools are never probed. """ from chipcompiler import rtl2gds as rtl2gds_api - return probe_components_for_steps(rtl2gds_api.get_flow_builders()[preset]()) + steps = rtl2gds_api.filter_flow_steps(rtl2gds_api.get_flow_builders()[preset](), skip) + return probe_components_for_steps(steps) def probe_components_for_steps(steps) -> tuple[str, ...]: diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py index 8bae7c56..da9d2e8c 100644 --- a/chipcompiler/engine/signoff/collector.py +++ b/chipcompiler/engine/signoff/collector.py @@ -134,7 +134,11 @@ def add_file( filler_verilog = workspace_dir / "filler_ecc" / "output" / f"{design}_filler.v.gz" # The canonical chain wires postRouteLec's gate input to the LVS output. lec_gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" - require_lec = self._requires_post_route_lec(lec_golden, lec_gate) + # A workspace whose ledger has no postRouteLec (skipped at creation) + # never requires it, regardless of the artifacts on disk. + require_lec = self.workspace.flow.has_step( + SkippableStepEnum.POST_ROUTE_LEC + ) and self._requires_post_route_lec(lec_golden, lec_gate) required_steps = self._required_step_states(require_lec=require_lec) for step_name, state in required_steps.items(): if state != StateEnum.Success.value: diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 2f22fae3..05666fa5 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -525,6 +525,10 @@ def _requires_post_route_lec(workspace: Workspace) -> bool: flow = getattr(workspace, "flow", None) if flow is None or not flow.has_step(StepEnum.LVS): return False + # A workspace whose ledger has no postRouteLec (skipped at creation) + # never requires it, regardless of the artifacts on disk. + if not flow.has_step(SkippableStepEnum.POST_ROUTE_LEC): + return False golden, gate = _post_route_lec_netlists(workspace) return bool(golden and Path(golden).is_file() and gate and Path(gate).is_file()) diff --git a/test/cli/commands/test_doctor.py b/test/cli/commands/test_doctor.py index 3a9789c7..e6e90d0a 100644 --- a/test/cli/commands/test_doctor.py +++ b/test/cli/commands/test_doctor.py @@ -260,6 +260,48 @@ def test_preflight_components_mapping(self, monkeypatch): "sizer", ) + def test_preflight_resolves_components_from_filtered_steps( + self, tmp_path, create_cli_project, monkeypatch + ): + """Skipping a step removes its tool from the preflight probe set.""" + from chipcompiler.cli.command_handlers.project import _preflight_environment + + monkeypatch.setattr( + "chipcompiler.rtl2gds.builder.build_rtl2gds_flow", + lambda *, skip=(): [ + (step, tool, "Unstart") + for step, tool, _state in [ + ("Synthesis", "yosys", "Unstart"), + ("lec", "yosys_lec", "Unstart"), + ("Floorplan", "ecc", "Unstart"), + ("Timing optimization", "sizer", "Unstart"), + ] + if step not in set(skip) + ], + ) + seen = {} + monkeypatch.setattr( + "chipcompiler.cli.inspection.env_probe.probe_components_for_steps", + lambda steps: seen.setdefault( + "tools", tuple(sorted({tool for _step, tool, _state in steps})) + ), + ) + monkeypatch.setattr( + "chipcompiler.cli.inspection.env_probe.probe_environment", + lambda components, **kwargs: [], + ) + + # No policy: the default skips lec; no flow_config: probe the preset + # filtered by that default. + _preflight_environment("rtl2gds", None, None) + assert "yosys_lec" not in seen["tools"] + assert seen["tools"] == ("ecc", "sizer", "yosys") + + # Skipping TimingOpt removes the sizer probe. + seen.clear() + _preflight_environment("rtl2gds", None, {"skip_steps": ["TimingOpt"]}) + assert "sizer" not in seen["tools"] + def test_workspace_run_mode_never_probes( self, tmp_path, monkeypatch, create_cli_project, minimal_ics55_pdk_factory ): @@ -326,7 +368,7 @@ def create_step_workspaces(self, *, executable_steps=None): def _capture_preset(seen): - def fake(preset): + def fake(preset, *, skip=()): seen["preset"] = preset return ("ecc-tools",) diff --git a/test/test_signoff_package.py b/test/test_signoff_package.py index 1de074f1..b58656b3 100644 --- a/test/test_signoff_package.py +++ b/test/test_signoff_package.py @@ -424,7 +424,8 @@ def test_collect_signoff_package_requires_proven_post_route_lec(tmp_path): ) -def test_collect_signoff_package_requires_post_route_lec_even_if_flow_omits_it(tmp_path): +def test_collect_signoff_package_skips_post_route_lec_when_flow_omits_it(tmp_path): + """A ledger without postRouteLec (skipped at creation) never requires it.""" workspace_dir = _make_signoff_workspace(tmp_path) flow = json.loads((workspace_dir / "home" / "flow.json").read_text()) flow["steps"] = [step for step in flow["steps"] if step.get("name") != "postRouteLec"] @@ -434,8 +435,9 @@ def test_collect_signoff_package_requires_post_route_lec_even_if_flow_omits_it(t SignoffPackageOptions(archive=False, materialize=False) ) - assert result.ok is False - assert any(issue.location == "postRouteLec" and issue.required for issue in result.issues) + assert result.ok is True + assert not any(issue.location == "postRouteLec" and issue.required for issue in result.issues) + assert not any("postRouteLec" in entry for entry in result.missing_required) def _rewrite_flow_without_synthesis(workspace_dir: Path, first_step: dict) -> None: @@ -835,3 +837,14 @@ def test_collect_signoff_package_packages_legacy_parameters_when_toml_absent(tmp assert (package_dir / "initial" / "parameters.json").is_file() summary = json.loads((package_dir / "summary.json").read_text()) assert summary["initial"]["parameters"] == "initial/parameters.json" + + +def test_signoff_required_qor_steps_contain_no_skippable_step(): + """The QoR required set must stay free of skippable steps: a workspace + that skipped one would false-fail its signoff. A future skippable + addition trips this deliberately.""" + from chipcompiler.data import SkippableStepEnum + from chipcompiler.engine.signoff import SIGNOFF_REQUIRED_QOR_STEPS + + skippable = {member.value for member in SkippableStepEnum} + assert not (SIGNOFF_REQUIRED_QOR_STEPS & skippable) diff --git a/test/tools/ecc/test_signoff_checklist.py b/test/tools/ecc/test_signoff_checklist.py index ab350714..ad783fbd 100644 --- a/test/tools/ecc/test_signoff_checklist.py +++ b/test/tools/ecc/test_signoff_checklist.py @@ -773,3 +773,38 @@ def test_rebuild_home_checklist_heals_empty_home_checklist_path(tmp_path): assert [item["id"] for item in healed["checklist"] if item["step"] == "STA"] == [ "sta.check.setup.timing" ] + + +def test_home_checklist_omits_post_route_lec_when_ledger_skips_it(tmp_path): + """A ledger without postRouteLec produces no missing flow item for it.""" + from chipcompiler.data import SkippableStepEnum, StateEnum, StepEnum + from chipcompiler.tools.ecc.signoff_checklist import rebuild_home_checklist + + workspace = Workspace(directory=tmp_path, design=OriginDesign(name="gcd")) + (tmp_path / "home").mkdir() + workspace.home.init(tmp_path / "home" / "home.json") + workspace.home.set_checklist(tmp_path / "home" / "checklist.json") + workspace.flow.path = tmp_path / "home" / "flow.json" + ledger_without_post_route_lec = [ + {"name": step.value, "tool": "ecc", "state": StateEnum.Success.value} + for step in (StepEnum.ROUTING, StepEnum.DRC, StepEnum.LVS, StepEnum.FILLER) + ] + [ + {"name": StepEnum.RCX.value, "tool": "ecc", "state": StateEnum.Success.value}, + {"name": StepEnum.STA.value, "tool": "ecc", "state": StateEnum.Success.value}, + {"name": StepEnum.HARDEN.value, "tool": "ecc", "state": StateEnum.Success.value}, + ] + assert all( + step["name"] != SkippableStepEnum.POST_ROUTE_LEC.value + for step in ledger_without_post_route_lec + ) + workspace.flow.data = {"steps": ledger_without_post_route_lec} + + rebuild_home_checklist(workspace) + + home_items = { + item["id"]: item + for item in json.loads((tmp_path / "home" / "checklist.json").read_text(encoding="utf-8"))[ + "checklist" + ] + } + assert "flow.postroutelec.completed" not in home_items From ac923fc1e9f2b339cc12d549b7d108dc05818609 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 23:00:46 +0800 Subject: [PATCH 08/19] test(data): ledger chaining behavior for skipped steps Workspace-creation behavior tests pin the input-chaining contract under each skip policy: the default keeps the synthesis LEC out of the ledger so preFloorplan directly consumes synthesis outputs; an explicit empty skip list restores the LEC entry; skipping Timing optimization chains routing after legalization and skipping postRouteLec chains DRC after LVS. Ledgers drive step-directory creation and step inputs, so a skipped step owns neither --- test/data/test_workspace.py | 116 ++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index ceb6ed4f..0b585fa5 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -243,6 +243,122 @@ def test_create_workspace_rejects_invalid_skip_steps_before_any_mutation( assert not workspace_dir.exists() +def test_create_workspace_default_policy_keeps_lec_out_of_the_ledger( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """No declared policy: the synthesis LEC never enters the ledger, so + preFloorplan directly follows synthesis and consumes its outputs.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + def_path = tmp_path / "gcd.def" + def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def=def_path, + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "preFloorplan"}, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [step["name"] for step in flow_data["steps"]] == ["Synthesis", "preFloorplan"] + assert [step["tool"] for step in flow_data["steps"]] == ["yosys", "ecc"] + + +def test_create_workspace_explicit_empty_skip_enables_lec_in_the_ledger( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """skip_steps = [] is the only LEC enable: the ledger carries the LEC + entry (with the golden netlist recorded) and preFloorplan still follows + the synthesis side of the chain.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + def_path = tmp_path / "gcd.def" + def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def=def_path, + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "preFloorplan", "skip_steps": []}, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("Synthesis", "yosys"), + ("lec", "yosys_lec"), + ("preFloorplan", "ecc"), + ] + + +def test_create_workspace_skip_timing_opt_chains_route_after_legalization( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={ + "start_step": "legalization", + "end_step": "route", + "skip_steps": ["TimingOpt"], + }, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("legalization", "dreamplace"), + ("route", "ecc"), + ] + + +def test_create_workspace_skip_post_route_lec_chains_drc_after_lvs( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={ + "start_step": "lvs", + "end_step": "drc", + "skip_steps": ["postRouteLec"], + }, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("lvs", "ecc"), + ("drc", "ecc"), + ] + + def test_create_workspace_copies_external_lec_and_sta_inputs( tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters ): From f4caebaf02ae1fc14917f40f1b3bd3e7061435f3 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 23:02:58 +0800 Subject: [PATCH 09/19] docs(flow): document skippable steps, precedence chain, and LEC default Document the skip mechanism on both development guides: the skippable set (lec, postRouteLec, Timing optimization), the skip-specific precedence (project.json over ecc.toml over the code default), explicit empty list as the only LEC enable, the synthesis_lec preset conflict, and ledger immutability under policy changes. Stale claims that the rtl2gds preset runs an active synthesis LEC by default are corrected to the default-skipped behavior. Forward compatibility is pinned: unknown flow_config keys never become ledger steps --- docs/development.cn.md | 22 +++++++++++++++++++++- docs/development.md | 35 ++++++++++++++++++++++++++++++++++- test/rtl2gds/test_builder.py | 16 ++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/development.cn.md b/docs/development.cn.md index afeed60c..8356d4db 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -484,12 +484,31 @@ uv run ecc pdk show ### Flow Preset 覆盖 -`ecc run --preset ` 单次覆盖 `[flow] preset`,不改 `ecc.toml`。合法名从 `chipcompiler/rtl2gds/builder.py` 自动发现(`rtl2gds | syn_sta | synthesis_lec`);`rtl2gds` preset 是完整的综合到 Harden 链(15 步,Synthesis 后紧跟一次综合级 LEC;Harden 产出 GDS + 抽象 LEF + 时序 LIB): +`ecc run --preset ` 单次覆盖 `[flow] preset`,不改 `ecc.toml`。合法名从 `chipcompiler/rtl2gds/builder.py` 自动发现(`rtl2gds | syn_sta | synthesis_lec`);`rtl2gds` preset 是完整的综合到 Harden 链(16 步,Synthesis 后紧跟一次综合级 LEC;Harden 产出 GDS + 抽象 LEF + 时序 LIB): ```bash uv run ecc run --project gcd --preset rtl2gds ``` +### 可跳过的 Flow Step + +三个可选 step 可在创建 workspace 时按配置排除:综合级 LEC(`lec`)、布线后 LEC(`postRouteLec`)、时序优化(`Timing optimization`)。被跳过的 step 不会进入 workspace 的执行 ledger——其输入自然落到前一个保留 step,也不会为其创建 step 目录。状态机与 resume/rerun 语义零改动;已创建的 ledger 永远不会按新配置重过滤——事后修改策略不会向已有 workspace 插入或删除 step。 + +策略在两个配置面声明,优先级对 skip_steps 单独生效: + +1. `project.json` → `workspaces[].skip_steps`(per-workspace,仅此键优先——显式空数组也生效), +2. `ecc.toml` → `[flow] skip_steps`(项目级), +3. 两者都未声明时的代码默认 `("lec",)`。 + +显式 `skip_steps = []` 表示全部执行,是启用综合级 LEC 的唯一方式。有效策略包含 `lec` 时选择 `synthesis_lec` preset 是创建期配置错误;解决办法是 `skip_steps = []`。条目接受与 flow 范围相同的别名(如 `LEC`、`postlec`、`TimingOpt`),并按可跳过集合校验。 + +```toml +[flow] +preset = "rtl2gds" +# LEC is skipped by default; clear the list to enable it. +skip_steps = ["lec"] +``` + ### 报告 `ecc report qor` 委托 `chipcompiler.analysis.qor` QoR v3 引擎。生产 Snapshot v2 的 `qorAssessment` 仅为当前 GUI 兼容保留,不是 v3 工程结论的第二来源。`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器: @@ -533,6 +552,7 @@ root = "/path/to/ics55" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec +# 可选:skip_steps = ["lec"](默认);[] 全部执行(启用 LEC) ``` filelist 模式下把 `design.rtl` 设为单个 filelist 路径,如 `rtl = ["rtl/filelist.f"]`。多 RTL 源应列在 filelist 里,而不是写多个 `design.rtl` 条目。 diff --git a/docs/development.md b/docs/development.md index 5e09dfe2..bf540eb7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -674,7 +674,7 @@ uv run ecc pdk show `ecc run --preset ` overrides `[flow] preset` for a single run without editing `ecc.toml`. Valid names are auto-discovered from `chipcompiler/rtl2gds/builder.py` (`rtl2gds | syn_sta | synthesis_lec`); the -`rtl2gds` preset is the full synthesis-to-harden chain (15 steps, with a +`rtl2gds` preset is the full synthesis-to-harden chain (16 steps, with a synthesis-level LEC immediately after Synthesis; Harden emits GDS + abstract LEF + timing LIB): @@ -682,6 +682,38 @@ emits GDS + abstract LEF + timing LIB): uv run ecc run --project gcd --preset rtl2gds ``` +### Skippable Flow Steps + +Three optional steps can be excluded from a workspace at creation time: +the synthesis LEC (`lec`), the post-route LEC (`postRouteLec`), and timing +optimization (`Timing optimization`). Skipped steps never enter the +workspace's execution ledger — their inputs fall through to the previous +retained step, and no step directory is created for them. State-machine, +resume/rerun semantics are unchanged, and existing ledgers are never +re-filtered: changing the policy later cannot insert or remove steps in a +created workspace. + +The policy is declared on two surfaces, with skip-specific precedence: + +1. `project.json` → `workspaces[].skip_steps` (per-workspace, wins for + this key only — including an explicit empty list), +2. `ecc.toml` → `[flow] skip_steps` (project level), +3. code default `("lec",)` when neither declares the key. + +An explicit `skip_steps = []` runs every step and is the only way to +enable the synthesis LEC. Selecting the `synthesis_lec` preset while the +effective policy skips `lec` is a creation-time configuration error; the +fix is `skip_steps = []`. Entries accept the same aliases as flow ranges +(e.g. `LEC`, `postlec`, `TimingOpt`) and are validated against the +skippable set. + +```toml +[flow] +preset = "rtl2gds" +# LEC is skipped by default; clear the list to enable it. +skip_steps = ["lec"] +``` + ### Reports `ecc report qor` delegates to the canonical QoR v3 Engine in @@ -734,6 +766,7 @@ root = "/path/to/ics55" [flow] preset = "rtl2gds" # rtl2gds | syn_sta | synthesis_lec +# Optional: skip_steps = ["lec"] (default); [] runs everything (enables LEC) ``` For filelist mode, set `design.rtl` to a single filelist path, for example diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index 2cd8c35b..fee07525 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -215,3 +215,19 @@ def test_policy_only_flow_config_never_yields_a_ledger(): assert build_dynamic_flow_data({"skip_steps": ["lec"]}) == {} assert build_dynamic_flow_data({"skip_steps": []}) == {} + + +def test_unknown_flow_config_keys_never_become_steps_or_errors(): + from chipcompiler.data.workspace import build_dynamic_flow_data + + ledger = build_dynamic_flow_data( + { + "start_step": "Synthesis", + "end_step": "Harden", + "future_unknown_key": {"nested": [1, 2, 3]}, + } + ) + + names = [step["name"] for step in ledger["steps"]] + assert "Synthesis" in names + assert "future_unknown_key" not in names From 9447d0fd53795c679b9e9f66ba1f6f75192f1aa9 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 14 Sep 2026 23:48:55 +0800 Subject: [PATCH 10/19] fix(flow): address plan-review findings for skippable steps - a skipped step can no longer bound a [flow] range: validate_flow_config rejects it, and create_workspace validates the resolved selection before any filesystem mutation - a policy-only flow config (skip_steps without a range) persists its normalized policy into _flow so runtime ledger-less rebuilds honor an explicit skip_steps = [] instead of silently falling back to the default - the preset-path _flow persistence normalizes skip_steps through validate_flow_config instead of copying raw aliases - run_existing carries the declared skip policy on the preset target so existing workspaces classify against the same policy fresh creation uses - ecc config shows the effective flow.skip_steps with its winning layer (or the code default) - EngineFlow.add_step accepts StepBaseEnum like init_flow_step - docs: corrected remaining stale claims that the synthesis LEC runs by default (cli-design, config ref, user guide, tutorial, development) --- chipcompiler/cli/inspection/config_view.py | 16 +++++++++++++ chipcompiler/cli/project/run_existing.py | 5 +++++ chipcompiler/cli/project/run_prepare.py | 11 ++++++--- chipcompiler/data/workspace/__init__.py | 17 ++++++++++++-- chipcompiler/data/workspace_config.py | 8 +++++++ chipcompiler/docs/ecc-config-ref.cn.md | 2 +- chipcompiler/docs/ecc-config-ref.en.md | 2 +- chipcompiler/docs/ecc-tutorial.en.md | 4 ++-- chipcompiler/docs/ecc-user-guide.en.md | 2 +- chipcompiler/engine/flow.py | 2 +- docs/development.cn.md | 2 +- docs/development.md | 7 +++--- docs/specification/cli-design.md | 4 +++- test/data/test_workspace.py | 26 ++++++++++++++++++++++ test/data/test_workspace_config.py | 12 ++++++++++ 15 files changed, 104 insertions(+), 16 deletions(-) diff --git a/chipcompiler/cli/inspection/config_view.py b/chipcompiler/cli/inspection/config_view.py index 8127835f..9b5a7464 100644 --- a/chipcompiler/cli/inspection/config_view.py +++ b/chipcompiler/cli/inspection/config_view.py @@ -80,6 +80,22 @@ def source_of(dotted: str) -> str: else: entries.append(("flow.preset", cfg.flow_preset, cfg.flow_preset, source_of("flow.preset"))) + # Effective skip policy: the declared spelling with its winning layer, + # or the code default when no surface declares the key. + declared = None + source = "ecc.toml" + if resolved is not None and isinstance(flow_config, dict) and "skip_steps" in flow_config: + declared = list(flow_config["skip_steps"]) + source = "project.json" if "flow.skip_steps" not in explicit else "ecc.toml" + elif "flow.skip_steps" in explicit: + declared = list(cfg.flow_skip_steps or []) + if declared is None: + from chipcompiler.data import DEFAULT_SKIP_STEPS + + entries.append(("flow.skip_steps", None, list(DEFAULT_SKIP_STEPS), "default")) + else: + entries.append(("flow.skip_steps", declared, declared, source)) + inspect = disclosure_cmd("ecc config", project, run_id) for key, value, resolved, source in entries: diff --git a/chipcompiler/cli/project/run_existing.py b/chipcompiler/cli/project/run_existing.py index 43cdc370..c750f43e 100644 --- a/chipcompiler/cli/project/run_existing.py +++ b/chipcompiler/cli/project/run_existing.py @@ -112,7 +112,12 @@ def mismatch_error(reason: str) -> CommandResult: # manifest's start/end seeded it at creation and is not consulted. target_section = None else: + # The declared skip policy rides on the preset target so an + # existing workspace classifies against the same policy a fresh + # creation would use (an explicit empty list included). target_section = {"preset": cfg.flow_preset} if cfg.flow_preset else None + if target_section is not None and "flow.skip_steps" in cfg._explicit_keys: + target_section["skip_steps"] = cfg.flow_skip_steps # Pure-read preflight: a divergent flow is rejected BEFORE load_workspace # can migrate configs, create home.json/checklist, or take the lock. diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 48c1f584..1bc91afa 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -453,12 +453,17 @@ def failed_workspace(reason: str | None) -> CommandResult: if not _flow_config_selects_steps(flow_config): # CLI-born workspaces persist the named preset chain as - # their target; a declared skip policy rides along. + # their target; a declared skip policy rides along, + # normalized (validate_flow_config is the normalizer). workspace_parameters = getattr(workspace, "parameters", None) if workspace_parameters is not None: - flow_section = {"preset": cfg.flow_preset} + flow_section: dict = {"preset": cfg.flow_preset} if isinstance(flow_config, dict) and "skip_steps" in flow_config: - flow_section["skip_steps"] = flow_config["skip_steps"] + from chipcompiler.data.workspace_config import validate_flow_config + + flow_section = validate_flow_config( + {"preset": cfg.flow_preset, "skip_steps": flow_config["skip_steps"]} + ) workspace_parameters.data["_flow"] = flow_section if not save_parameter(workspace_parameters): return failed_workspace("failed to persist the flow target in params.toml") diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index fefb4f5e..f8c7054e 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -1057,11 +1057,15 @@ def create_workspace( - input_filelist takes priority over origin_verilog for synthesis when both exist - All input files are copied to workspace/origin/ directory """ - # The skip policy is validated before anything on disk is touched: an - # invalid policy is a configuration error, never a partial workspace. + # The skip policy and the selected range are validated before anything + # on disk is touched: invalid configuration is an error, never a + # partial workspace. A skipped step cannot bound the range either. from chipcompiler.rtl2gds import resolve_skip_steps + from ..workspace_config import flow_section_from_flow_config + resolve_skip_steps(flow_config) + flow_section_from_flow_config(flow_config) # create workspace directory import shutil @@ -1171,6 +1175,15 @@ def create_workspace( dynamic_flow_data["steps"][0]["info"]["spef"] = str(workspace.pdk.spef) if not json_write(workspace.flow.path, workspace.flow.data): raise OSError(f"Failed to write initial flow.json: {workspace.flow.path}") + elif isinstance(flow_config, dict) and "skip_steps" in flow_config: + # A policy-only flow config selects no steps (the preset or the + # ledger-less rebuild owns the chain), but the declared policy must + # still persist so later rebuilds resolve the same chain. + from ..workspace_config import validate_flow_config + + workspace.parameters.data["_flow"] = validate_flow_config( + {"skip_steps": flow_config["skip_steps"]} + ) if workspace.pdk.root: workspace.parameters.data["pdk_root"] = str(workspace.pdk.root) diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index f0f52834..7cd7756f 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -158,6 +158,14 @@ def validate_flow_config(flow: object) -> dict: if value not in canonical_names: raise WorkspaceFlowTargetError(f"[flow] unknown step name: {value!r}") normalized[key] = value + excluded = set(result.get("skip_steps") or ()) + for key in ("start", "end"): + if normalized[key] in excluded: + raise WorkspaceFlowTargetError( + f"[flow] {normalized[key]!r} is skipped by skip_steps and cannot " + f"bound the flow range; remove it from skip_steps or pick another " + f"boundary" + ) if canonical_names.index(normalized["start"]) > canonical_names.index(normalized["end"]): raise WorkspaceFlowTargetError( f"[flow] start {normalized['start']!r} is after end {normalized['end']!r}" diff --git a/chipcompiler/docs/ecc-config-ref.cn.md b/chipcompiler/docs/ecc-config-ref.cn.md index 614fd5bb..a65f125f 100644 --- a/chipcompiler/docs/ecc-config-ref.cn.md +++ b/chipcompiler/docs/ecc-config-ref.cn.md @@ -481,7 +481,7 @@ Timing optimization 是三阶段子流程:运行 Sizer,用 DreamPlace 对 Si | 输出 | `output/<设计>_postRouteLec_result.json`:`status`(`proven` / 失败)+ 双方 `sha256` + 报告路径;`report/equiv_status.rpt`、`report/run_lec_status.rpt` | | 签核 | `status=proven` 计入签核清单(LEC 结果进签核包 `final/reports/postRouteLec/`) | -完整 `rtl2gds` preset 会在 synthesis 后立即执行 `lec`。另有 `synthesis_lec` preset(仅 synthesis + lec 两步)可单独做综合级等价检查。 +完整 `rtl2gds` preset 的链路中 `lec` 紧跟 synthesis,但**默认被跳过**(`[flow] skip_steps` 默认为 `["lec"]`;设为 `[]` 才会执行)。另有 `synthesis_lec` preset(仅 synthesis + lec 两步)可单独做综合级等价检查;该 preset 需要显式 `skip_steps = []`。 ## 12. rcx(ecc-tools) diff --git a/chipcompiler/docs/ecc-config-ref.en.md b/chipcompiler/docs/ecc-config-ref.en.md index 3d88185f..146dea6b 100644 --- a/chipcompiler/docs/ecc-config-ref.en.md +++ b/chipcompiler/docs/ecc-config-ref.en.md @@ -479,7 +479,7 @@ No JSON configuration; driven by `script/run_lec.tcl` (read liberty → normaliz | Output | `output/_postRouteLec_result.json`: `status` (`proven` / failure) + both sides' `sha256` + report paths; `report/equiv_status.rpt`, `report/run_lec_status.rpt` | | Signoff | `status=proven` counts toward the signoff checklist (LEC results go into the signoff package `final/reports/postRouteLec/`) | -The `lec` step runs immediately after synthesis in the complete `rtl2gds` preset. There is also a `synthesis_lec` preset (just the two steps synthesis + lec) for standalone synthesis-level equivalence checking. +The `lec` step sits immediately after synthesis in the canonical `rtl2gds` chain but is **skipped by default** (`[flow] skip_steps` defaults to `["lec"]`; set `skip_steps = []` to run it). There is also a `synthesis_lec` preset (just the two steps synthesis + lec) for standalone synthesis-level equivalence checking; it requires `skip_steps = []`. ## 12. rcx (ecc-tools) diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index d6763f88..e01dfb60 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -254,7 +254,7 @@ rc=0 ### 4.1 Start -The `rtl2gds` preset is the full 17-step chain, running all the way through Harden (which produces the GDS + abstract LEF + timing LIB): +The `rtl2gds` preset is the full 17-step chain, running all the way through Harden (which produces the GDS + abstract LEF + timing LIB). The synthesis LEC (step 2) is part of the chain but **skipped by default**: `[flow] skip_steps` defaults to `["lec"]`, and setting `skip_steps = []` in `ecc.toml` is the only way to run it. The captured outputs in this tutorial were produced with the LEC enabled: ```bash ecc run --preset rtl2gds @@ -749,7 +749,7 @@ The list must cover **every** hard macro in the design and use real instance nam ## 8. Next Steps - Try your own design: edit `top`/`rtl`/`clock_port`/`frequency_mhz` in `ecc.toml`; use a [filelist](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist) for multi-file designs; -- Preset differences: `rtl2gds` (the complete 17-step synthesis-to-Harden chain, including synthesis-level LEC), `syn_sta` (synthesis only), and `synthesis_lec` (synthesis + LEC, two steps); +- Preset differences: `rtl2gds` (the complete 17-step synthesis-to-Harden chain; the synthesis-level LEC is in the chain but skipped by default — `skip_steps = []` enables it), `syn_sta` (synthesis only), and `synthesis_lec` (synthesis + LEC, two steps, requires `skip_steps = []`); - Full command details in the **[ECC CLI User Guide](ecc-user-guide.en.md)** (`ecc doc ug`); extending the CLI is covered in [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli); - Driving the flow directly via the Python API (`EngineFlow`): [examples/gcd/ics55flow.py](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/ics55flow.py). diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index a17c297d..65f60544 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -327,7 +327,7 @@ ecc run [OPTIONS] --plain key=value output for scripting ``` -For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). +For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). A summary is printed when the run finishes (real output): diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 7575e3bd..1879ac75 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -171,7 +171,7 @@ def init_flow_step( def add_step( self, - step: StepEnum | str, + step: StepBaseEnum | str, tool: str, state: str | StateEnum, info: dict | None = None, diff --git a/docs/development.cn.md b/docs/development.cn.md index 8356d4db..cc1edc99 100644 --- a/docs/development.cn.md +++ b/docs/development.cn.md @@ -484,7 +484,7 @@ uv run ecc pdk show ### Flow Preset 覆盖 -`ecc run --preset ` 单次覆盖 `[flow] preset`,不改 `ecc.toml`。合法名从 `chipcompiler/rtl2gds/builder.py` 自动发现(`rtl2gds | syn_sta | synthesis_lec`);`rtl2gds` preset 是完整的综合到 Harden 链(16 步,Synthesis 后紧跟一次综合级 LEC;Harden 产出 GDS + 抽象 LEF + 时序 LIB): +`ecc run --preset ` 单次覆盖 `[flow] preset`,不改 `ecc.toml`。合法名从 `chipcompiler/rtl2gds/builder.py` 自动发现(`rtl2gds | syn_sta | synthesis_lec`);`rtl2gds` preset 是完整的综合到 Harden 链(规范链 17 步,含 Synthesis 后紧跟的综合级 LEC,默认策略会跳过该步,见下文「可跳过的 Flow Step」;Harden 产出 GDS + 抽象 LEF + 时序 LIB): ```bash uv run ecc run --project gcd --preset rtl2gds diff --git a/docs/development.md b/docs/development.md index bf540eb7..682f7c39 100644 --- a/docs/development.md +++ b/docs/development.md @@ -674,9 +674,10 @@ uv run ecc pdk show `ecc run --preset ` overrides `[flow] preset` for a single run without editing `ecc.toml`. Valid names are auto-discovered from `chipcompiler/rtl2gds/builder.py` (`rtl2gds | syn_sta | synthesis_lec`); the -`rtl2gds` preset is the full synthesis-to-harden chain (16 steps, with a -synthesis-level LEC immediately after Synthesis; Harden -emits GDS + abstract LEF + timing LIB): +`rtl2gds` preset is the full synthesis-to-harden chain (17 canonical steps, +including a synthesis-level LEC immediately after Synthesis that the default +skip policy excludes — see [Skippable Flow Steps](#skippable-flow-steps); +Harden emits GDS + abstract LEF + timing LIB): ```bash uv run ecc run --project gcd --preset rtl2gds diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md index 0e845c01..b6fee035 100644 --- a/docs/specification/cli-design.md +++ b/docs/specification/cli-design.md @@ -579,7 +579,9 @@ flow presets are discovered from the `build_*_flow` defs in `chipcompiler/rtl2gds/builder.py` (currently `rtl2gds`, `syn_sta`, and `synthesis_lec`). The `rtl2gds` preset includes synthesis-level LEC immediately after synthesis, followed by every physical-design step -through RCX, STA, and Harden; `syn_sta` runs synthesis only, with a best-effort netlist-level STA report +through RCX, STA, and Harden; the synthesis LEC is skipped by default +(`[flow] skip_steps` defaults to `["lec"]`; an explicit `[]` enables it); +`syn_sta` runs synthesis only, with a best-effort netlist-level STA report (an STA failure does not fail the step). Switching presets on an existing run requires `ecc run --overwrite` to rebuild the workspace. diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 0b585fa5..9a002d3c 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -1780,3 +1780,29 @@ def test_create_workspace_pdk_overrides_typo_propagates( pdk_root=str(pdk_root), pdk_overrides={"dontuse": ["ICG*"]}, ) + + +def test_create_workspace_policy_only_config_persists_declared_policy( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """A policy-only flow config (no selected steps) still persists the + declared policy, so ledger-less rebuilds resolve the same chain.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + workspace = create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"skip_steps": []}, + ) + + assert workspace is not None + assert not (workspace_dir / "home" / "flow.json").exists() + loaded = load_workspace(str(workspace_dir)) + assert loaded.parameters.data["_flow"] == {"skip_steps": []} diff --git a/test/data/test_workspace_config.py b/test/data/test_workspace_config.py index 66f86d59..cfde760a 100644 --- a/test/data/test_workspace_config.py +++ b/test/data/test_workspace_config.py @@ -472,3 +472,15 @@ def test_save_drops_null_list_elements_with_a_warning(tmp_path): assert ok is True loaded = load_workspace_config(tmp_path) assert loaded["core"]["margin"] == [2] + + +def test_flow_section_rejects_skipped_step_as_range_boundary(): + with pytest.raises(WorkspaceFlowTargetError, match="cannot bound the flow range"): + validate_flow_config({"start": "Synthesis", "end": "lec", "skip_steps": ["lec"]}) + # Same rule when the boundary step is the start. + with pytest.raises(WorkspaceFlowTargetError, match="cannot bound the flow range"): + validate_flow_config({"start": "lec", "end": "Harden", "skip_steps": ["LEC"]}) + # A skipped step INSIDE the range is fine. + assert validate_flow_config( + {"start": "Synthesis", "end": "preFloorplan", "skip_steps": ["lec"]} + ) == {"start": "Synthesis", "end": "preFloorplan", "skip_steps": ["lec"]} From de05150890cb473763e5901085e142b0e70e9840 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 00:33:57 +0800 Subject: [PATCH 11/19] refactor: address plan-review round 2 findings - extract post-route LEC gating into tools/ecc/lec_gates.py so signoff_checklist.py stays under the module size guideline - move the synthesis_lec preset / skip-policy run tests into test/cli/commands/test_run_skip_policy.py - render_workspace_config persists the validated (normalized) [flow] section, so a declared policy lands canonical in params.toml no matter which path saves it - run_existing preset target carries the declared skip policy so existing workspaces classify against the creation-time policy - correct remaining Chinese user-guide/tutorial claims that the synthesis LEC runs by default --- chipcompiler/data/workspace_config.py | 10 +- chipcompiler/docs/ecc-tutorial.cn.md | 4 +- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/tools/ecc/lec_gates.py | 45 ++++++++ chipcompiler/tools/ecc/signoff_checklist.py | 38 ++----- test/cli/commands/test_migrate.py | 4 +- test/cli/commands/test_run.py | 73 ------------- test/cli/commands/test_run_skip_policy.py | 111 ++++++++++++++++++++ test/data/test_workspace_config.py | 16 +++ 9 files changed, 189 insertions(+), 114 deletions(-) create mode 100644 chipcompiler/tools/ecc/lec_gates.py create mode 100644 test/cli/commands/test_run_skip_policy.py diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index 7cd7756f..06bda30c 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -412,10 +412,10 @@ def render_workspace_config( """Render the workspace configuration TOML document. ``pdk_config`` values inside the workspace are stored - workspace-relative; *flow* is validated before rendering. + workspace-relative; *flow* is validated — and its normalized form + (e.g. canonical ``skip_steps``) — is what gets rendered. """ - if flow: - validate_flow_config(flow) + normalized_flow = validate_flow_config(flow) if flow else None workspace_root = Path(workspace_dir).resolve() payload = _drop_null_values(dict(data)) @@ -430,8 +430,8 @@ def render_workspace_config( "design": sections["design"], "pdk": sections["pdk"], } - if flow: - document["flow"] = dict(flow) + if normalized_flow: + document["flow"] = dict(normalized_flow) document["params"] = sections["params"] return tomli_w.dumps(document).encode("utf-8") diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index 88c8cda2..369ed3de 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -253,7 +253,7 @@ rc=0 ### 4.1 启动 -`rtl2gds` preset 是完整 17 步链,一步到位跑到 Harden(产出 GDS + 抽象 LEF + 时序 LIB): +`rtl2gds` preset 是完整 17 步链,一步到位跑到 Harden(产出 GDS + 抽象 LEF + 时序 LIB)。综合级 LEC(第 2 步)在链路中但**默认被跳过**:`[flow] skip_steps` 默认为 `["lec"]`,在 `ecc.toml` 中设 `skip_steps = []` 是启用它的唯一方式。本教程捕获的输出均在启用 LEC 的条件下产生: ```bash ecc run --preset rtl2gds @@ -748,7 +748,7 @@ ecc run --workspace default ## 8. 下一步 - 换你自己的设计:改 `ecc.toml` 的 `top`/`rtl`/`clock_port`/`frequency_mhz`,多文件用 [filelist](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/README.md#using-filelist); -- 了解 preset 差异:`rtl2gds`(完整 17 步综合到 Harden 链,含综合级 LEC)、`syn_sta`(仅综合)、`synthesis_lec`(综合 + LEC,两步); +- 了解 preset 差异:`rtl2gds`(完整 17 步综合到 Harden 链;综合级 LEC 在链路中但默认跳过——`skip_steps = []` 启用)、`syn_sta`(仅综合)、`synthesis_lec`(综合 + LEC,两步,需要 `skip_steps = []`); - 全部命令细节见 **[ECC CLI 用户指南](ecc-user-guide.cn.md)**(终端:`ecc doc ug --lang cn`);CLI 扩展开发见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli); - 用 Python API 直接编排 flow(`EngineFlow`)见 [examples/gcd/ics55flow.py](https://github.com/openecos-projects/ecc/blob/main/docs/examples/gcd/ics55flow.py)。 diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 2f1d067f..467e3c34 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -326,7 +326,7 @@ ecc run [OPTIONS] --plain 面向脚本的 key=value 输出 ``` -新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 +新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 运行结束打印汇总(真实输出): diff --git a/chipcompiler/tools/ecc/lec_gates.py b/chipcompiler/tools/ecc/lec_gates.py new file mode 100644 index 00000000..629f081a --- /dev/null +++ b/chipcompiler/tools/ecc/lec_gates.py @@ -0,0 +1,45 @@ +"""Post-route LEC gating for the signoff checklist. + +Whether a workspace's checklist and signoff artifacts require the +post-route LEC, and which netlists the requirement compares: ledger +membership first (a workspace created with the step skipped never +requires it), then the golden/gate artifacts on disk. +""" + +from pathlib import Path + +from chipcompiler.data import SkippableStepEnum, StepEnum, Workspace + + +def post_route_lec_netlists(workspace: Workspace) -> tuple[Path | None, Path | None]: + """(golden, gate) netlists the post-route LEC proof compares.""" + design = getattr(getattr(workspace, "design", None), "name", "") or "" + # Golden precedence mirrors the execution wiring (engine/flow.py): the + # synthesis output when the flow contains Synthesis, else the declared + # golden netlist, else the origin RTL. + golden = getattr(getattr(workspace, "design", None), "origin_verilog", None) + gate = None + workspace_dir = Path(workspace.directory) if getattr(workspace, "directory", None) else None + flow = getattr(workspace, "flow", None) + if workspace_dir is not None: + # The canonical chain wires postRouteLec's gate input to the LVS + # output netlist (the step immediately before it), not the filler one. + gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" + if flow is not None and flow.has_step(StepEnum.SYNTHESIS): + golden = workspace_dir / "Synthesis_yosys" / "output" / f"{design}_Synthesis.v.gz" + else: + golden = getattr(workspace.design, "golden_verilog", None) or golden + return golden, gate + + +def requires_post_route_lec(workspace: Workspace) -> bool: + """Whether postRouteLec is a required, checkable step for *workspace*.""" + flow = getattr(workspace, "flow", None) + if flow is None or not flow.has_step(StepEnum.LVS): + return False + # A workspace whose ledger has no postRouteLec (skipped at creation) + # never requires it, regardless of the artifacts on disk. + if not flow.has_step(SkippableStepEnum.POST_ROUTE_LEC): + return False + golden, gate = post_route_lec_netlists(workspace) + return bool(golden and Path(golden).is_file() and gate and Path(gate).is_file()) diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py index 05666fa5..390f3124 100644 --- a/chipcompiler/tools/ecc/signoff_checklist.py +++ b/chipcompiler/tools/ecc/signoff_checklist.py @@ -17,6 +17,12 @@ WorkspaceStep, ) from chipcompiler.data.step import STEP_DIRECTORIES +from chipcompiler.tools.ecc.lec_gates import ( + post_route_lec_netlists as _post_route_lec_netlists, +) +from chipcompiler.tools.ecc.lec_gates import ( + requires_post_route_lec as _requires_post_route_lec, +) from chipcompiler.tools.ecc.sta_qor import ( STA_QOR_SUMMARY_FILENAME, STA_REPORT_FILENAMES, @@ -501,38 +507,6 @@ def refresh_step_checklist(workspace: Workspace, step: WorkspaceStep) -> bool: return not any(item["blocked"] for item in step.checklist.checklist) -def _post_route_lec_netlists(workspace: Workspace) -> tuple[Path | None, Path | None]: - design = getattr(getattr(workspace, "design", None), "name", "") or "" - # Golden precedence mirrors the execution wiring (engine/flow.py): the - # synthesis output when the flow contains Synthesis, else the declared - # golden netlist, else the origin RTL. - golden = getattr(getattr(workspace, "design", None), "origin_verilog", None) - gate = None - workspace_dir = Path(workspace.directory) if getattr(workspace, "directory", None) else None - flow = getattr(workspace, "flow", None) - if workspace_dir is not None: - # The canonical chain wires postRouteLec's gate input to the LVS - # output netlist (the step immediately before it), not the filler one. - gate = workspace_dir / "lvs_ecc" / "output" / f"{design}_lvs.v.gz" - if flow is not None and flow.has_step(StepEnum.SYNTHESIS): - golden = workspace_dir / "Synthesis_yosys" / "output" / f"{design}_Synthesis.v.gz" - else: - golden = getattr(workspace.design, "golden_verilog", None) or golden - return golden, gate - - -def _requires_post_route_lec(workspace: Workspace) -> bool: - flow = getattr(workspace, "flow", None) - if flow is None or not flow.has_step(StepEnum.LVS): - return False - # A workspace whose ledger has no postRouteLec (skipped at creation) - # never requires it, regardless of the artifacts on disk. - if not flow.has_step(SkippableStepEnum.POST_ROUTE_LEC): - return False - golden, gate = _post_route_lec_netlists(workspace) - return bool(golden and Path(golden).is_file() and gate and Path(gate).is_file()) - - def _flow_items(workspace: Workspace) -> list[dict]: flow = getattr(workspace, "flow", None) states = { diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index 98453753..b52991a2 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -478,7 +478,9 @@ def test_persisted_skip_policy_carries_into_the_manifest_entry( assert rc == 0 (workspace,) = _manifest(project_dir)["workspaces"] - assert workspace["skip_steps"] == ["TimingOpt"] + # The persisted policy is normalized by save_workspace_config, so + # the manifest entry carries the canonical step value. + assert workspace["skip_steps"] == ["Timing optimization"] def test_undeclared_and_empty_skip_policies_stay_distinct_after_migration( self, diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 58c642f1..3be0fd5d 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -139,79 +139,6 @@ def test_run_preserves_final_records( class TestRunFlowPreset: - @pytest.mark.parametrize( - "preset,builder_attr", - [ - ("rtl2gds", "build_rtl2gds_flow"), - ("syn_sta", "build_syn_sta_flow"), - ("synthesis_lec", "build_synthesis_lec_flow"), - ], - ) - def test_run_dispatches_builder_for_preset( - self, tmp_path, monkeypatch, create_cli_project, flow_mocks, preset, builder_attr - ): - project_dir = create_cli_project() - _set_flow_preset(project_dir, preset) - if preset == "synthesis_lec": - # The preset needs the synthesis LEC the default policy skips; - # an explicit empty skip list is the only enable path. - with open(os.path.join(project_dir, "ecc.toml")) as f: - toml = f.read() - toml += "\nskip_steps = []\n" - with open(os.path.join(project_dir, "ecc.toml"), "w") as f: - f.write(toml) - markers = _patch_all_flow_builders(monkeypatch) - - rc = cli_main.run(["run", "--project", project_dir]) - - assert rc == 0 - assert flow_mocks.flow.instances[0].added_steps == markers[builder_attr] - - def test_synthesis_lec_preset_conflicts_with_default_skip_policy( - self, tmp_path, capsys, create_cli_project, flow_mocks - ): - project_dir = create_cli_project() - _set_flow_preset(project_dir, "synthesis_lec") - # No skip_steps declared: the code default skips lec. - - rc = cli_main.run(["run", "--project", project_dir]) - - assert rc == 1 - assert "skip_steps = []" in capsys.readouterr().out - assert flow_mocks.capture["create_kwargs"] is None - - def test_synthesis_lec_preset_conflict_never_fires_for_existing_ledger( - self, tmp_path, create_cli_project, flow_mocks - ): - import json - - from chipcompiler.data.workspace_config import save_workspace_config - - project_dir = create_cli_project() - _set_flow_preset(project_dir, "synthesis_lec") - run_dir = os.path.join(project_dir, "default") - home = os.path.join(run_dir, "home") - os.makedirs(home, exist_ok=True) - with open(os.path.join(home, "flow.json"), "w") as f: - json.dump( - { - "steps": [ - {"name": "Synthesis", "tool": "yosys", "state": "Success"}, - {"name": "lec", "tool": "yosys_lec", "state": "Success"}, - ] - }, - f, - ) - assert save_workspace_config( - run_dir, - {"pdk": "ics55", "design": "gcd", "top_module": "gcd", "clock": "clk"}, - {"preset": "synthesis_lec"}, - ) - - rc = cli_main.run(["run", "--project", project_dir, "--resume"]) - - assert rc == 0 - def test_run_overwrite_rebuilds_flow_with_new_preset( self, tmp_path, monkeypatch, create_cli_project, create_flow_json, flow_mocks ): diff --git a/test/cli/commands/test_run_skip_policy.py b/test/cli/commands/test_run_skip_policy.py new file mode 100644 index 00000000..66dc9ca0 --- /dev/null +++ b/test/cli/commands/test_run_skip_policy.py @@ -0,0 +1,111 @@ +"""synthesis_lec preset vs skip-policy interaction on `ecc run`. + +The preset exists to run the synthesis LEC the default policy skips, so +a fresh creation needs an explicit `skip_steps = []` while an existing +ledger is never re-filtered. +""" + +import json +import os + +import pytest + +from chipcompiler.cli import main as cli_main + + +def _set_flow_preset(project_dir, preset): + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace('preset = "rtl2gds"', f'preset = "{preset}"') + with open(toml_path, "w") as f: + f.write(content) + + +def _patch_all_flow_builders(monkeypatch): + markers = {} + for attr in ("build_rtl2gds_flow", "build_syn_sta_flow", "build_synthesis_lec_flow"): + steps = [("Synthesis", "yosys", "Unstart"), (attr, "ecc", "Unstart")] + markers[attr] = steps + monkeypatch.setattr(f"chipcompiler.rtl2gds.builder.{attr}", lambda steps=steps: steps) + return markers + + +def _enable_lec_in(project_dir): + """Append an explicit empty skip list — the only LEC enable path.""" + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + toml = f.read() + toml += "\nskip_steps = []\n" + with open(toml_path, "w") as f: + f.write(toml) + + +@pytest.mark.parametrize( + ("preset", "builder_attr"), + [ + ("rtl2gds", "build_rtl2gds_flow"), + ("syn_sta", "build_syn_sta_flow"), + ("synthesis_lec", "build_synthesis_lec_flow"), + ], +) +def test_run_dispatches_builder_for_preset( + tmp_path, monkeypatch, create_cli_project, flow_mocks, preset, builder_attr +): + project_dir = create_cli_project() + _set_flow_preset(project_dir, preset) + if preset == "synthesis_lec": + # The preset needs the synthesis LEC the default policy skips; + # an explicit empty skip list is the only enable path. + _enable_lec_in(project_dir) + markers = _patch_all_flow_builders(monkeypatch) + + rc = cli_main.run(["run", "--project", project_dir]) + + assert rc == 0 + assert flow_mocks.flow.instances[0].added_steps == markers[builder_attr] + + +def test_synthesis_lec_preset_conflicts_with_default_skip_policy( + tmp_path, capsys, create_cli_project, flow_mocks +): + project_dir = create_cli_project() + _set_flow_preset(project_dir, "synthesis_lec") + # No skip_steps declared: the code default skips lec. + + rc = cli_main.run(["run", "--project", project_dir]) + + assert rc == 1 + assert "skip_steps = []" in capsys.readouterr().out + assert flow_mocks.capture["create_kwargs"] is None + + +def test_synthesis_lec_preset_conflict_never_fires_for_existing_ledger( + tmp_path, create_cli_project, flow_mocks +): + from chipcompiler.data.workspace_config import save_workspace_config + + project_dir = create_cli_project() + _set_flow_preset(project_dir, "synthesis_lec") + run_dir = os.path.join(project_dir, "default") + home = os.path.join(run_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump( + { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "lec", "tool": "yosys_lec", "state": "Success"}, + ] + }, + f, + ) + assert save_workspace_config( + run_dir, + {"pdk": "ics55", "design": "gcd", "top_module": "gcd", "clock": "clk"}, + {"preset": "synthesis_lec"}, + ) + + rc = cli_main.run(["run", "--project", project_dir, "--resume"]) + + assert rc == 0 diff --git a/test/data/test_workspace_config.py b/test/data/test_workspace_config.py index cfde760a..3bc8efca 100644 --- a/test/data/test_workspace_config.py +++ b/test/data/test_workspace_config.py @@ -484,3 +484,19 @@ def test_flow_section_rejects_skipped_step_as_range_boundary(): assert validate_flow_config( {"start": "Synthesis", "end": "preFloorplan", "skip_steps": ["lec"]} ) == {"start": "Synthesis", "end": "preFloorplan", "skip_steps": ["lec"]} + + +def test_save_persists_normalized_skip_steps(tmp_path): + """save_workspace_config renders the validated [flow] section, so the + persisted policy is canonical regardless of the declared spelling.""" + from chipcompiler.data.workspace_config import load_workspace_config + + payload = {"design": "gcd", "top_module": "gcd", "clock": "clk"} + assert save_workspace_config( + tmp_path, payload, {"start": "Synthesis", "end": "Harden", "skip_steps": ["TimingOpt"]} + ) + + raw = (tmp_path / "home" / "params.toml").read_text() + assert "Timing optimization" in raw + assert "TimingOpt" not in raw + assert load_workspace_config(tmp_path)["_flow"]["skip_steps"] == ["Timing optimization"] From b1dca46224bb013477e68a3372dc62fb22407341 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 01:13:31 +0800 Subject: [PATCH 12/19] fix(cli): close plan-review round 3 findings - fresh manifest registration materializes the workspace's declared skip policy onto the project.json entry (declared spelling preserved) - the existing-workspace run path classifies against the effective declared policy (flow_config's resolved skip_steps, falling back to ecc.toml), so a per-workspace project.json policy wins over ecc.toml as with fresh creation - preset-shaped flow configs now build the preset's ledger and persist {preset, skip_steps} through the data/runtime creation path instead of being silently dropped - docs: the synthesis_lec examples in both user guides note the required skip_steps = [], and both tutorials show clearing the list before the captured run --- chipcompiler/cli/project/run_dispatch.py | 1 + chipcompiler/cli/project/run_existing.py | 15 ++++++---- chipcompiler/data/workspace/__init__.py | 16 ++++++---- chipcompiler/data/workspace_config.py | 7 +++++ chipcompiler/docs/ecc-tutorial.cn.md | 5 +++- chipcompiler/docs/ecc-tutorial.en.md | 5 +++- chipcompiler/docs/ecc-user-guide.cn.md | 2 +- chipcompiler/docs/ecc-user-guide.en.md | 3 +- chipcompiler/project/manifest_write.py | 10 +++++++ test/cli/project/test_skip_steps_config.py | 35 ++++++++++++++++++++++ test/rtl2gds/test_builder.py | 20 +++++++++++++ 11 files changed, 105 insertions(+), 14 deletions(-) diff --git a/chipcompiler/cli/project/run_dispatch.py b/chipcompiler/cli/project/run_dispatch.py index fc84189a..87fba100 100644 --- a/chipcompiler/cli/project/run_dispatch.py +++ b/chipcompiler/cli/project/run_dispatch.py @@ -237,6 +237,7 @@ def existing_workspace_run() -> CommandResult: run_name, cli_overrides, warning_records, + flow_config=flow_config, workspace_registered=workspace_registered, ) diff --git a/chipcompiler/cli/project/run_existing.py b/chipcompiler/cli/project/run_existing.py index c750f43e..07885f5e 100644 --- a/chipcompiler/cli/project/run_existing.py +++ b/chipcompiler/cli/project/run_existing.py @@ -26,6 +26,7 @@ def run_existing_workspace( cli_overrides: dict, warning_records: list[dict], *, + flow_config=None, workspace_registered: bool, ) -> CommandResult: """Run against an existing workspace: reconcile target vs persisted flow. @@ -112,12 +113,16 @@ def mismatch_error(reason: str) -> CommandResult: # manifest's start/end seeded it at creation and is not consulted. target_section = None else: - # The declared skip policy rides on the preset target so an - # existing workspace classifies against the same policy a fresh - # creation would use (an explicit empty list included). + # The target carries the preset plus the effective declared skip + # policy (already resolved through skip-specific precedence onto + # the flow config), so an existing workspace classifies against + # the same policy a fresh creation would use. target_section = {"preset": cfg.flow_preset} if cfg.flow_preset else None - if target_section is not None and "flow.skip_steps" in cfg._explicit_keys: - target_section["skip_steps"] = cfg.flow_skip_steps + if target_section is not None: + if isinstance(flow_config, dict) and "skip_steps" in flow_config: + target_section["skip_steps"] = flow_config["skip_steps"] + elif "flow.skip_steps" in cfg._explicit_keys: + target_section["skip_steps"] = cfg.flow_skip_steps # Pure-read preflight: a divergent flow is rejected BEFORE load_workspace # can migrate configs, create home.json/checklist, or take the lock. diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index f8c7054e..de073dde 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -285,16 +285,22 @@ def build_dynamic_flow_data(flow_config: dict | None) -> dict: A non-contiguous explicit selection degrades to the contiguous first..last range (with a log note) so flow.json and the [flow] target - always describe the same steps. The config's skip policy is resolved - here: skipped steps never enter the ledger. + always describe the same steps. A preset-shaped config selects the + preset's canonical range. The config's skip policy is resolved here: + skipped steps never enter the ledger. """ if not isinstance(flow_config, dict) or not flow_config: return {} - canonical_steps = _canonical_rtl2gds_flow_entries() - from ..workspace_config import resolve_flow_selection + from ..workspace_config import flow_range_for_preset, resolve_flow_selection - selected_names, _degraded = resolve_flow_selection(flow_config, canonical_steps) + if "preset" in flow_config and "start_step" not in flow_config and "steps" not in flow_config: + first, last = flow_range_for_preset(flow_config["preset"]) + selected_names = [first, last] + else: + selected_names, _degraded = resolve_flow_selection( + flow_config, _canonical_rtl2gds_flow_entries() + ) if not selected_names: return {} diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index 06bda30c..bc1389de 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -239,6 +239,13 @@ def flow_section_from_flow_config(flow_config: dict | None) -> dict: if not isinstance(flow_config, dict) or not flow_config: return {} + # A preset-shaped flow_config selects the preset's canonical range. + if "preset" in flow_config and "start_step" not in flow_config and "steps" not in flow_config: + section: dict = {"preset": flow_config["preset"]} + if "skip_steps" in flow_config: + section["skip_steps"] = flow_config["skip_steps"] + return validate_flow_config(section) + from chipcompiler.data.workspace import _canonical_rtl2gds_flow_entries selected, _degraded = resolve_flow_selection(flow_config, _canonical_rtl2gds_flow_entries()) diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md index 369ed3de..973fc0be 100644 --- a/chipcompiler/docs/ecc-tutorial.cn.md +++ b/chipcompiler/docs/ecc-tutorial.cn.md @@ -253,9 +253,12 @@ rc=0 ### 4.1 启动 -`rtl2gds` preset 是完整 17 步链,一步到位跑到 Harden(产出 GDS + 抽象 LEF + 时序 LIB)。综合级 LEC(第 2 步)在链路中但**默认被跳过**:`[flow] skip_steps` 默认为 `["lec"]`,在 `ecc.toml` 中设 `skip_steps = []` 是启用它的唯一方式。本教程捕获的输出均在启用 LEC 的条件下产生: +`rtl2gds` preset 是完整 17 步链,一步到位跑到 Harden(产出 GDS + 抽象 LEF + 时序 LIB)。综合级 LEC(第 2 步)在链路中但**默认被跳过**:`[flow] skip_steps` 默认为 `["lec"]`,在 `ecc.toml` 中设 `skip_steps = []` 是启用它的唯一方式。要完整复现下文展示的每一步(包括 LEC),先清空该列表再运行: ```bash +# 为本教程启用综合级 LEC +sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml + ecc run --preset rtl2gds ``` diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md index e01dfb60..5390a9b8 100644 --- a/chipcompiler/docs/ecc-tutorial.en.md +++ b/chipcompiler/docs/ecc-tutorial.en.md @@ -254,9 +254,12 @@ rc=0 ### 4.1 Start -The `rtl2gds` preset is the full 17-step chain, running all the way through Harden (which produces the GDS + abstract LEF + timing LIB). The synthesis LEC (step 2) is part of the chain but **skipped by default**: `[flow] skip_steps` defaults to `["lec"]`, and setting `skip_steps = []` in `ecc.toml` is the only way to run it. The captured outputs in this tutorial were produced with the LEC enabled: +The `rtl2gds` preset is the full 17-step chain, running all the way through Harden (which produces the GDS + abstract LEF + timing LIB). The synthesis LEC (step 2) is part of the chain but **skipped by default**: `[flow] skip_steps` defaults to `["lec"]`, and setting `skip_steps = []` in `ecc.toml` is the only way to run it. To reproduce every step shown below (including the LEC), clear the list once before running: ```bash +# enable the synthesis LEC for this tutorial +sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml + ecc run --preset rtl2gds ``` diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 467e3c34..0d57cbe3 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -328,7 +328,7 @@ ecc run [OPTIONS] 新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 -运行结束打印汇总(真实输出): +`synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先在 `ecc.toml` 中显式设置 `skip_steps = []`: ```console $ ecc run --preset synthesis_lec diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 65f60544..7793acb1 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -329,7 +329,8 @@ ecc run [OPTIONS] For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). -A summary is printed when the run finishes (real output): +The `synthesis_lec` preset requires the LEC the default policy skips, so this +example's project sets an explicit `skip_steps = []` in `ecc.toml` first: ```console $ ecc run --preset synthesis_lec diff --git a/chipcompiler/project/manifest_write.py b/chipcompiler/project/manifest_write.py index b386db11..daec98a2 100644 --- a/chipcompiler/project/manifest_write.py +++ b/chipcompiler/project/manifest_write.py @@ -363,6 +363,14 @@ def pre_register_workspace( start_step, end_step = manifest_range_for_flow(cfg, flow_config) except ManifestError: return "failed" + # A declared skip policy is materialized on the entry (declared + # spelling preserved); an undeclared policy stays absent so the code + # default keeps applying on later reads. + declared_skip = ( + flow_config.get("skip_steps") + if isinstance(flow_config, dict) and "skip_steps" in flow_config + else None + ) now = _now_iso() manifest_path = os.path.join(project_dir, MANIFEST_FILENAME) if not os.path.lexists(manifest_path): @@ -375,6 +383,7 @@ def pre_register_workspace( start_step=start_step, end_step=end_step, status="not_started", + skip_steps=list(declared_skip) if declared_skip is not None else None, ) if write_manifest_if_absent(project_dir, document): return "registered" @@ -409,6 +418,7 @@ def mutate(document: dict) -> None: end_step=end_step, status="not_started", now=now, + skip_steps=list(declared_skip) if declared_skip is not None else None, ) ) document["updated_at"] = now diff --git a/test/cli/project/test_skip_steps_config.py b/test/cli/project/test_skip_steps_config.py index e3dfca25..1b9aea8b 100644 --- a/test/cli/project/test_skip_steps_config.py +++ b/test/cli/project/test_skip_steps_config.py @@ -183,3 +183,38 @@ def test_init_materializes_the_default_skip_into_generated_ecc_toml(tmp_path): toml = (tmp_path / "gcd" / "ecc.toml").read_text() assert 'skip_steps = ["lec"]' in toml assert "LEC is skipped by default; clear the list to enable it." in toml + + +def test_pre_register_materializes_declared_skip_steps(tmp_path, monkeypatch): + """A fresh manifest registration records the workspace's declared + skip policy on the entry (declared spelling preserved).""" + from chipcompiler.cli.project.config import load_project_config + from chipcompiler.cli.project.manifest_write import pre_register_workspace + + (tmp_path / "ecc.toml").write_text( + "[design]\n" + 'name = "gcd"\n' + 'top = "gcd"\n' + 'rtl = ["rtl/gcd.v"]\n' + 'clock_port = "clk"\n' + "frequency_mhz = 100.0\n" + "\n[pdk]\n" + 'name = "ics55"\n' + 'root = "/pdk"\n' + "\n[flow]\n" + 'preset = "rtl2gds"\n' + ) + cfg = load_project_config(str(tmp_path / "ecc.toml")) + + outcome = pre_register_workspace( + str(tmp_path), + cfg=cfg, + pdk_root="/pdk", + workspace_id="ws_0001", + workspace_path=str(tmp_path / "ws_0001"), + flow_config={"skip_steps": ["TimingOpt"]}, + ) + + assert outcome == "registered" + (entry,) = load_manifest(str(tmp_path)).workspaces + assert entry.skip_steps == ("TimingOpt",) diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index fee07525..e682ab23 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -231,3 +231,23 @@ def test_unknown_flow_config_keys_never_become_steps_or_errors(): names = [step["name"] for step in ledger["steps"]] assert "Synthesis" in names assert "future_unknown_key" not in names + + +def test_preset_shaped_flow_config_builds_the_preset_ledger(): + from chipcompiler.data.workspace import build_dynamic_flow_data + from chipcompiler.data.workspace_config import flow_section_from_flow_config + + policy = {"preset": "synthesis_lec", "skip_steps": []} + + ledger = build_dynamic_flow_data(policy) + assert [step["name"] for step in ledger["steps"]] == ["Synthesis", "lec"] + + # The persisted flow section keeps the preset and the normalized policy. + section = flow_section_from_flow_config(policy) + assert section == {"preset": "synthesis_lec", "skip_steps": []} + + # The default policy conflicts with this preset's LEC endpoint: the + # skipped boundary is a deterministic unknown-step error (the data-level + # mirror of the creation-time preset conflict). + with pytest.raises(ValueError, match="unknown flow step"): + build_dynamic_flow_data({"preset": "synthesis_lec"}) From 18e74a7c03a072122930a4a36a44b3f2a21b7fa9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 01:31:36 +0800 Subject: [PATCH 13/19] fix(cli): resolve ledgers before mutation and honor manifest skip policy on existing runs - create_workspace fully resolves the ledger (including a preset target whose endpoint the effective policy skips) before any filesystem mutation, so the conflict surfaces as a clean error with a pristine target instead of a partially created workspace - manifest-mode existing runs now apply the effective declared skip policy (project.json workspaces[].skip_steps over ecc.toml) over the workspace's own [flow] range, so classification, extension, and no-op/resume decisions match fresh creation while the seeded range contract is preserved --- chipcompiler/cli/project/run_existing.py | 39 +++++++++++++++++++-- chipcompiler/data/workspace/__init__.py | 12 +++---- test/cli/commands/test_flow_continuation.py | 32 +++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/chipcompiler/cli/project/run_existing.py b/chipcompiler/cli/project/run_existing.py index 07885f5e..6a895178 100644 --- a/chipcompiler/cli/project/run_existing.py +++ b/chipcompiler/cli/project/run_existing.py @@ -17,6 +17,36 @@ from chipcompiler.data import is_finished_step_state +def _manifest_skip_target(run_dir: str, flow_config) -> dict | None: + """Manifest-mode target: the workspace's own [flow] range plus the + effective declared skip policy. + + None when no policy is declared (the workspace's persisted policy + keeps governing) or when the workspace config carries no range (the + persisted-ledger fallback stays in charge). + """ + declared = ( + flow_config.get("skip_steps") + if isinstance(flow_config, dict) and "skip_steps" in flow_config + else None + ) + if declared is None: + return None + from chipcompiler.data.workspace_config import ( + WorkspaceConfigError, + WorkspaceFlowTargetError, + load_workspace_config, + ) + + try: + workspace_flow = load_workspace_config(run_dir)["_flow"] + except (FileNotFoundError, OSError, WorkspaceConfigError, WorkspaceFlowTargetError): + return None + if "start" not in workspace_flow or "end" not in workspace_flow: + return None + return {**workspace_flow, "skip_steps": declared} + + def run_existing_workspace( command_input, ctx, @@ -109,9 +139,12 @@ def mismatch_error(reason: str) -> CommandResult: ) if cfg.manifest_driven: - # Manifest mode: the workspace's own [flow] is the target; the - # manifest's start/end seeded it at creation and is not consulted. - target_section = None + # Manifest mode: the workspace's own [flow] governs the range (the + # seeded start/end is not re-consulted), but the effective declared + # skip policy (project.json over ecc.toml, carried on the flow + # config) is applied over it so classification and any extension + # use the same policy a fresh creation would. + target_section = _manifest_skip_target(run_dir, flow_config) else: # The target carries the preset plus the effective declared skip # policy (already resolved through skip-specific precedence onto diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index de073dde..0b06d563 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -1063,15 +1063,14 @@ def create_workspace( - input_filelist takes priority over origin_verilog for synthesis when both exist - All input files are copied to workspace/origin/ directory """ - # The skip policy and the selected range are validated before anything - # on disk is touched: invalid configuration is an error, never a - # partial workspace. A skipped step cannot bound the range either. + # The skip policy, the selected range, and the resulting ledger are + # fully resolved before anything on disk is touched: invalid + # configuration (including a preset target whose endpoint the policy + # skips) is an error, never a partial workspace. from chipcompiler.rtl2gds import resolve_skip_steps - from ..workspace_config import flow_section_from_flow_config - resolve_skip_steps(flow_config) - flow_section_from_flow_config(flow_config) + dynamic_flow_data = build_dynamic_flow_data(flow_config) # create workspace directory import shutil @@ -1162,7 +1161,6 @@ def create_workspace( workspace.home.set_flow(workspace.flow.path) workspace.home.set_checklist(home_dir / "checklist.json") workspace.home.set_parameters(workspace.parameters.path) - dynamic_flow_data = build_dynamic_flow_data(flow_config) if dynamic_flow_data: from chipcompiler.utility import json_write diff --git a/test/cli/commands/test_flow_continuation.py b/test/cli/commands/test_flow_continuation.py index 2a8b7b57..f531c97d 100644 --- a/test/cli/commands/test_flow_continuation.py +++ b/test/cli/commands/test_flow_continuation.py @@ -254,6 +254,38 @@ def test_manifest_backed_mismatch_leaves_every_surface_untouched( assert _tree_snapshot(run_dir) == tree_before assert Path(manifest_path).read_bytes() == manifest_before + def test_manifest_declared_skip_policy_is_honored_on_existing_run( + self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records + ): + """A per-workspace project.json skip policy wins over the + workspace's undeclared one on an existing run: enabling the LEC + (skip_steps = []) against a without-LEC ledger is a mismatch that + never inserts the step.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = os.path.join(project_dir, "ws_0001") + # A post-default-policy ledger: the canonical chain without the LEC. + without_lec = [name for name in RTL2GDS_NAMES if name != "lec"] + prefix = without_lec[:3] + _write_existing_workspace( + run_dir, prefix, states=["Success"] * 2 + ["Unstart"], pdk_root=pdk_root + ) + + manifest_path = _write_manifest_with_workspace(project_dir, run_dir, pdk_root) + with open(manifest_path) as f: + document = json.load(f) + document["workspaces"][0]["skip_steps"] = [] + with open(manifest_path, "w") as f: + json.dump(document, f, indent=2) + + rc = cli_main.run(["run", "--project", project_dir, "--plain"]) + + assert rc != 0 + errors = [r for r in _records(capsys, plain_records) if r.get("error") == "flow_mismatch"] + assert len(errors) == 1 + ledger = json.loads((Path(run_dir) / "home" / "flow.json").read_text()) + assert "lec" not in [step["name"] for step in ledger["steps"]] + def test_legacy_parameters_mismatch_never_migrates( self, tmp_path, capsys, create_cli_project, minimal_ics55_pdk_factory, plain_records ): From 253655720fa6ce6f271a2143f94db0251133260f Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 01:53:11 +0800 Subject: [PATCH 14/19] docs(index): update rtl2gds flow step count and default LEC skip --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index efe18b71..b31a1eeb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`): - **[CLI Tutorial](../chipcompiler/docs/ecc-tutorial.en.md)** / **[中文教程](../chipcompiler/docs/ecc-tutorial.cn.md)** - From zero to RTL → Harden with a signoff package - Installing the ecc CLI, PDK, and Yosys - - First project, the 15-step `rtl2gds` flow, signoff package, and reports + - First project, the 17-step `rtl2gds` flow (synthesis LEC skipped by default), signoff package, and reports - Tuning parameters, workspaces, and rerun scenarios - **[CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md)** / **[中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md)** - All currently supported commands - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`rpc`/`layout-image` From cc541058ef6b0360de7d49ce123fbad8af07d506 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 03:04:29 +0800 Subject: [PATCH 15/19] refactor: address whole-branch review findings - validate_flow_config rejects a preset whose endpoint the declared skip policy excludes, at config-validation time; manifest entries reject a skipped range boundary at load time - reconcile's skip-tolerant comparison only ignores ledger entries whose (name, tool) pair matches the canonical chain, so a corrupted entry is never treated as an inert skipped step - EngineFlow.build_default_steps delegates to the canonical rtl2gds builder instead of maintaining a second hand-written chain - flow-config-to-ledger construction (build_dynamic_flow_data and helpers) extracted into data/workspace/flow_data.py, keeping workspace/__init__.py's size in check; skip-policy workspace and migration tests moved into focused modules - migration blocks (instead of silently defaulting) when a workspace's persisted skip_steps is invalid, naming the offending config - ecc config reports the winning skip_steps layer recorded by effective-config resolution - shared preset-test helpers moved to test/cli/commands/conftest.py - user guides show the generated skip_steps line and the exact edit before the synthesis_lec example --- chipcompiler/cli/inspection/config_view.py | 6 +- chipcompiler/cli/project/effective_config.py | 10 +- chipcompiler/cli/project/migrate_plan.py | 18 +- chipcompiler/data/workspace/__init__.py | 122 ++---------- chipcompiler/data/workspace/flow_data.py | 110 +++++++++++ chipcompiler/data/workspace_config.py | 10 +- chipcompiler/docs/ecc-user-guide.cn.md | 4 +- chipcompiler/docs/ecc-user-guide.en.md | 5 +- chipcompiler/engine/flow.py | 30 +-- chipcompiler/engine/reconcile.py | 16 +- test/cli/commands/conftest.py | 30 +++ test/cli/commands/test_migrate.py | 64 ------- test/cli/commands/test_migrate_skip_policy.py | 80 ++++++++ test/cli/commands/test_run.py | 50 +++-- test/cli/commands/test_run_skip_policy.py | 39 ++-- test/data/test_workspace.py | 164 ---------------- test/data/test_workspace_skip_policy.py | 177 ++++++++++++++++++ 17 files changed, 522 insertions(+), 413 deletions(-) create mode 100644 chipcompiler/data/workspace/flow_data.py create mode 100644 test/cli/commands/test_migrate_skip_policy.py create mode 100644 test/data/test_workspace_skip_policy.py diff --git a/chipcompiler/cli/inspection/config_view.py b/chipcompiler/cli/inspection/config_view.py index 9b5a7464..7a7dfc58 100644 --- a/chipcompiler/cli/inspection/config_view.py +++ b/chipcompiler/cli/inspection/config_view.py @@ -84,9 +84,13 @@ def source_of(dotted: str) -> str: # or the code default when no surface declares the key. declared = None source = "ecc.toml" + winner = getattr(cfg, "_skip_steps_source", None) if resolved is not None and isinstance(flow_config, dict) and "skip_steps" in flow_config: declared = list(flow_config["skip_steps"]) - source = "project.json" if "flow.skip_steps" not in explicit else "ecc.toml" + # The winner was resolved by effective-config: manifest entry beats + # ecc.toml for this key; fall back to layer presence for older + # call paths that did not record it. + source = winner or ("project.json" if "flow.skip_steps" not in explicit else "ecc.toml") elif "flow.skip_steps" in explicit: declared = list(cfg.flow_skip_steps or []) if declared is None: diff --git a/chipcompiler/cli/project/effective_config.py b/chipcompiler/cli/project/effective_config.py index b77c8de1..2ea2e285 100644 --- a/chipcompiler/cli/project/effective_config.py +++ b/chipcompiler/cli/project/effective_config.py @@ -111,7 +111,15 @@ def resolve_effective_config( flow_config = {"start_step": entry.start_step, "end_step": entry.end_step} # skip_steps is the one key where the manifest layer outranks ecc.toml. - flow_config = _attach_skip_steps(flow_config, declared_skip_steps(entry, cfg)) + declared = declared_skip_steps(entry, cfg) + flow_config = _attach_skip_steps(flow_config, declared) + # Provenance for inspection surfaces: the winning layer's name. + if declared is not None: + cfg._skip_steps_source = ( + "project.json" + if (entry is not None and getattr(entry, "skip_steps", None) is not None) + else "ecc.toml" + ) warnings = [] diverging = layer_divergences(cfg, assembled, entry) diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 94d5c1c1..27c3d774 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -177,6 +177,10 @@ def _is_contiguous_flow(names: list[str]) -> bool: return False +class _InvalidPersistedSkipSteps(ValueError): + """A workspace's persisted [flow] skip_steps is invalid (unreadable policy).""" + + @deprecated( "legacy runs/ -> manifest layout migration machinery; slated for removal " "after the transition period", @@ -204,8 +208,8 @@ def _persisted_skip_steps(run_dir: str) -> tuple[str, ...] | None: try: resolve_skip_steps({"skip_steps": flow["skip_steps"]}) - except ValueError: - return None + except ValueError as exc: + raise _InvalidPersistedSkipSteps(str(exc)) from None return tuple(flow["skip_steps"]) @@ -273,6 +277,14 @@ def plan_migration(project_dir: str) -> MigrationPlan: if os.path.lexists(target): collisions.append(run_id) continue + try: + persisted_skip = _persisted_skip_steps(source) + except _InvalidPersistedSkipSteps as exc: + blocked[run_id] = ( + f"the workspace's persisted [flow] skip_steps is invalid ({exc}); " + f"fix home/params.toml and retry" + ) + continue steps = _read_flow_steps(source) if steps is None: blocked[run_id] = ( @@ -302,7 +314,7 @@ def plan_migration(project_dir: str) -> MigrationPlan: status=_flow_status(steps), start_step=CANONICAL_TO_DISPLAY.get(names[0], "Synth") if names else "Synth", end_step=CANONICAL_TO_DISPLAY.get(names[-1], "Harden") if names else "Harden", - skip_steps=_persisted_skip_steps(source), + skip_steps=persisted_skip, source_dev=source_stat.st_dev, source_ino=source_stat.st_ino, ) diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 0b06d563..4f904b5a 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -22,7 +22,14 @@ load_parameter as load_parameter, ) from ..pdk import PDK, get_pdk -from ..types import SkippableStepEnum, StateEnum, StepBaseEnum, StepEnum +from ..types import ( + SkippableStepEnum, + StepBaseEnum, + StepEnum, +) +from ..types import ( + StateEnum as StateEnum, +) from ..workspace_config import ( legacy_parameters_fallback as legacy_parameters_fallback, ) @@ -33,6 +40,18 @@ workspace_config_path as workspace_config_toml_path, ) from .filelist_copy import copy_filelist_with_sources as copy_filelist_with_sources +from .flow_data import ( + _canonical_rtl2gds_flow_entries as _canonical_rtl2gds_flow_entries, +) +from .flow_data import ( + _flow_step_template as _flow_step_template, +) +from .flow_data import ( + _selected_dynamic_flow_step_names as _selected_dynamic_flow_step_names, +) +from .flow_data import ( + build_dynamic_flow_data as build_dynamic_flow_data, +) from .layout import EccData, WorkspaceStepBase from .macro_location import refresh_generated_macro_location from .sdc import create_default_sdc as create_default_sdc @@ -280,107 +299,6 @@ def build_workspace_config_paths(workspace: Workspace) -> dict[str, Path]: return workspace_config_paths(workspace_dir) -def build_dynamic_flow_data(flow_config: dict | None) -> dict: - """Build initial flow.json data from GUI-provided flow_config. - - A non-contiguous explicit selection degrades to the contiguous - first..last range (with a log note) so flow.json and the [flow] target - always describe the same steps. A preset-shaped config selects the - preset's canonical range. The config's skip policy is resolved here: - skipped steps never enter the ledger. - """ - if not isinstance(flow_config, dict) or not flow_config: - return {} - - from ..workspace_config import flow_range_for_preset, resolve_flow_selection - - if "preset" in flow_config and "start_step" not in flow_config and "steps" not in flow_config: - first, last = flow_range_for_preset(flow_config["preset"]) - selected_names = [first, last] - else: - selected_names, _degraded = resolve_flow_selection( - flow_config, _canonical_rtl2gds_flow_entries() - ) - if not selected_names: - return {} - - import chipcompiler.rtl2gds as rtl2gds_api - - skip = rtl2gds_api.resolve_skip_steps(flow_config) - selected = rtl2gds_api.build_flow_range(selected_names[0], selected_names[-1], skip=skip) - return { - "steps": [ - _flow_step_template( - name.value if isinstance(name, StepBaseEnum) else str(name), - str(tool), - state.value if isinstance(state, StateEnum) else str(state), - ) - for name, tool, state in selected - ] - } - - -def _canonical_rtl2gds_flow_entries() -> list[tuple[str, str, str]]: - import chipcompiler.rtl2gds as rtl2gds_api - - return [ - ( - step.value if isinstance(step, StepBaseEnum) else str(step), - str(tool), - state.value if isinstance(state, StateEnum) else str(state), - ) - for step, tool, state in rtl2gds_api.build_rtl2gds_flow() - ] - - -def _selected_dynamic_flow_step_names( - flow_config: dict, - canonical_steps: list[tuple[str, str, str]], -) -> list[str]: - canonical_names = [name for name, _tool, _state in canonical_steps] - canonical_name_set = set(canonical_names) - - raw_steps = flow_config.get("steps", []) - if isinstance(raw_steps, str): - raw_steps = [raw_steps] - if isinstance(raw_steps, (list, tuple)): - requested = { - name - for name in (_normalize_flow_step_name(item) for item in raw_steps) - if name in canonical_name_set - } - if requested: - return [name for name in canonical_names if name in requested] - - start_step = _normalize_flow_step_name(flow_config.get("start_step")) - end_step = _normalize_flow_step_name(flow_config.get("end_step")) - if start_step not in canonical_name_set or end_step not in canonical_name_set: - return [] - - start_index = canonical_names.index(start_step) - end_index = canonical_names.index(end_step) - start = min(start_index, end_index) - end = max(start_index, end_index) - return canonical_names[start : end + 1] - - -def _normalize_flow_step_name(value) -> str: - from chipcompiler.rtl2gds import normalize_flow_step - - return normalize_flow_step(value) - - -def _flow_step_template(name: str, tool: str, state: str) -> dict: - return { - "name": name, - "tool": tool, - "state": state, - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - - @dataclass(frozen=True) class WorkspaceConfigParameterMapping: parameter_key: str diff --git a/chipcompiler/data/workspace/flow_data.py b/chipcompiler/data/workspace/flow_data.py new file mode 100644 index 00000000..2ed1fb91 --- /dev/null +++ b/chipcompiler/data/workspace/flow_data.py @@ -0,0 +1,110 @@ +"""Flow-config to ledger construction for workspaces. + +Turns a creation ``flow_config`` (preset, range, or explicit step +selection, plus an optional declared skip policy) into the initial +``flow.json`` ledger data and the canonical chain entries every consumer +(reconcile, config validation) slices from. Pure computation: no IO. +""" + +from chipcompiler.data.types import StateEnum, StepBaseEnum + + +def _canonical_rtl2gds_flow_entries() -> list[tuple[str, str, str]]: + import chipcompiler.rtl2gds as rtl2gds_api + + return [ + ( + step.value if isinstance(step, StepBaseEnum) else str(step), + str(tool), + state.value if isinstance(state, StateEnum) else str(state), + ) + for step, tool, state in rtl2gds_api.build_rtl2gds_flow() + ] + + +def build_dynamic_flow_data(flow_config: dict | None) -> dict: + """Build initial flow.json data from GUI-provided flow_config. + + A non-contiguous explicit selection degrades to the contiguous + first..last range (with a log note) so flow.json and the [flow] target + always describe the same steps. A preset-shaped config selects the + preset's canonical range. The config's skip policy is resolved here: + skipped steps never enter the ledger. + """ + if not isinstance(flow_config, dict) or not flow_config: + return {} + + from ..workspace_config import flow_range_for_preset, resolve_flow_selection + + if "preset" in flow_config and "start_step" not in flow_config and "steps" not in flow_config: + first, last = flow_range_for_preset(flow_config["preset"]) + selected_names = [first, last] + else: + selected_names, _degraded = resolve_flow_selection( + flow_config, _canonical_rtl2gds_flow_entries() + ) + if not selected_names: + return {} + + import chipcompiler.rtl2gds as rtl2gds_api + + skip = rtl2gds_api.resolve_skip_steps(flow_config) + selected = rtl2gds_api.build_flow_range(selected_names[0], selected_names[-1], skip=skip) + return { + "steps": [ + _flow_step_template( + name.value if isinstance(name, StepBaseEnum) else str(name), + str(tool), + state.value if isinstance(state, StateEnum) else str(state), + ) + for name, tool, state in selected + ] + } + + +def _selected_dynamic_flow_step_names( + flow_config: dict, + canonical_steps: list[tuple[str, str, str]], +) -> list[str]: + canonical_names = [name for name, _tool, _state in canonical_steps] + canonical_name_set = set(canonical_names) + + raw_steps = flow_config.get("steps", []) + if isinstance(raw_steps, str): + raw_steps = [raw_steps] + if isinstance(raw_steps, (list, tuple)): + requested = { + name + for name in (_normalize_flow_step_name(item) for item in raw_steps) + if name in canonical_name_set + } + if requested: + return [name for name in canonical_names if name in requested] + + start_step = _normalize_flow_step_name(flow_config.get("start_step")) + end_step = _normalize_flow_step_name(flow_config.get("end_step")) + if start_step not in canonical_name_set or end_step not in canonical_name_set: + return [] + + start_index = canonical_names.index(start_step) + end_index = canonical_names.index(end_step) + start = min(start_index, end_index) + end = max(start_index, end_index) + return canonical_names[start : end + 1] + + +def _normalize_flow_step_name(value) -> str: + from chipcompiler.rtl2gds import normalize_flow_step + + return normalize_flow_step(value) + + +def _flow_step_template(name: str, tool: str, state: str) -> dict: + return { + "name": name, + "tool": tool, + "state": state, + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py index bc1389de..b9a87037 100644 --- a/chipcompiler/data/workspace_config.py +++ b/chipcompiler/data/workspace_config.py @@ -142,7 +142,15 @@ def validate_flow_config(flow: object) -> dict: if preset is not None: if not isinstance(preset, str) or not preset.strip(): raise WorkspaceFlowTargetError(f"[flow] preset must be a non-empty string: {preset!r}") - flow_range_for_preset(preset) # raises on unknown presets + first, last = flow_range_for_preset(preset) # raises on unknown presets + excluded = set(result.get("skip_steps") or ()) + for boundary in (first, last): + if boundary in excluded: + raise WorkspaceFlowTargetError( + f"[flow] {boundary!r} (the {preset!r} preset boundary) is skipped by " + f"skip_steps and cannot be part of the flow target; set skip_steps = [] " + f"or pick another preset" + ) result["preset"] = preset return result diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md index 0d57cbe3..b9aea986 100644 --- a/chipcompiler/docs/ecc-user-guide.cn.md +++ b/chipcompiler/docs/ecc-user-guide.cn.md @@ -202,6 +202,8 @@ root = "" # icsprout55-pdk 路径;留空则用 CHIPCOMPILER_ICS [flow] # preset: rtl2gds | syn_sta | synthesis_lec preset = "rtl2gds" +# LEC is skipped by default; clear the list to enable it. +skip_steps = ["lec"] ``` ## 4. check — 校验项目配置 @@ -328,7 +330,7 @@ ecc run [OPTIONS] 新建或 `--overwrite` 的 workspace 会按以下流程执行:读 `ecc.toml` → 只解析入口步骤所需的设计文件以及 PDK/参数 → 预检所需工具 → 先写入 `project.json` 登记 → 在 `/` 创建 workspace → 将声明的设计输入复制到 `origin/`、写入对应步骤配置并运行 flow。workspace 不会存放第二份项目输入清单。已有 workspace 按持久化 flow 续跑,不会改写已有输入或步骤配置。`rtl2gds` 是完整 17 步链(Synthesis→LEC(Yosys 等价性检查;默认跳过——`[flow] skip_steps` 默认为 `["lec"]`,设为 `[]` 才启用)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization(sizer)→route→filler→RCX→sta→LVS→postRouteLec(Yosys 等价性检查)→DRC→Harden,Harden 产出 GDS + 抽象 LEF + 时序 LIB)。 -`synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先在 `ecc.toml` 中显式设置 `skip_steps = []`: +`synthesis_lec` preset 需要默认策略跳过的 LEC,因此本示例的项目先编辑 `ecc.toml`(`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` 或手动修改)显式设置 `skip_steps = []`: ```console $ ecc run --preset synthesis_lec diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index 7793acb1..db19bae1 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -203,6 +203,8 @@ root = "" # icsprout55-pdk path; empty falls back to CHIPCOMPILER [flow] # preset: rtl2gds | syn_sta | synthesis_lec preset = "rtl2gds" +# LEC is skipped by default; clear the list to enable it. +skip_steps = ["lec"] ``` ## 4. check — validate the project configuration @@ -330,7 +332,8 @@ ecc run [OPTIONS] For a fresh or `--overwrite` workspace, the pipeline reads `ecc.toml` → resolves only the design files required by the entry step plus PDK/parameters → preflights bundled ecc-tools plus the selected tools → records the workspace in `project.json` → creates it under `/` → copies its declared design inputs to `origin/`, writes the resulting step configuration, and executes the selected flow. A workspace never stores a second project input manifest. Existing workspaces resume their persisted flow without rewriting its inputs or step configuration. `rtl2gds` is the full 17-step chain (Synthesis→LEC (Yosys equivalence check; skipped by default — `[flow] skip_steps` defaults to `["lec"]`, set `[]` to enable)→preFloorplan→macroPlacement→postFloorplan→place→CTS→legalization→Timing optimization (sizer)→route→filler→RCX→sta→LVS→postRouteLec (Yosys equivalence check)→DRC→Harden; Harden emits GDS + abstract LEF + timing LIB). The `synthesis_lec` preset requires the LEC the default policy skips, so this -example's project sets an explicit `skip_steps = []` in `ecc.toml` first: +example's project edits `ecc.toml` first (`sed -i 's/skip_steps = \["lec"\]/skip_steps = []/' ecc.toml` +or any editor): ```console $ ecc run --preset synthesis_lec diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 1879ac75..db9707d8 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -119,30 +119,20 @@ def __init__(self, workspace: Workspace, engine_db: EngineDB = None): self.load() def build_default_steps(self): - # Flow step sequences - steps = [] + """Seed the canonical rtl2gds chain (test helper; the CLI/GUI paths + resolve their own target and policy before seeding).""" + from chipcompiler.rtl2gds import build_rtl2gds_flow - steps.append(self.init_flow_step(StepEnum.SYNTHESIS, "yosys", StateEnum.Unstart)) + steps = [ + self.init_flow_step(step, tool, state) for step, tool, state in build_rtl2gds_flow() + ] # Persist the golden netlist on the LEC step so reloads do not have # to guess roles from the golden_* filename convention. golden = getattr(self.workspace.design, "golden_verilog", None) - lec_info = {"golden_verilog": str(golden)} if golden else None - steps.append( - self.init_flow_step( - SkippableStepEnum.LEC, "yosys_lec", StateEnum.Unstart, info=lec_info - ) - ) - steps.append(self.init_flow_step(StepEnum.PRE_FLOORPLAN, "ecc", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.MACRO_PLACEMENT, "dreamplace", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.POST_FLOORPLAN, "ecc", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.CTS, "ecc", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.LEGALIZATION, "dreamplace", StateEnum.Unstart)) - steps.append(self.init_flow_step(SkippableStepEnum.TIMING_OPT, "sizer", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.ROUTING, "ecc", StateEnum.Unstart)) - steps.append(self.init_flow_step(StepEnum.FILLER, "ecc", StateEnum.Unstart)) - # steps.append(self.init_flow_step(StepEnum.GDS, "klayout", StateEnum.Unstart)) - # steps.append(self.init_flow_step(StepEnum.SIGNOFF, "ecc", StateEnum.Unstart)) + if golden: + for step in steps: + if step["name"] == SkippableStepEnum.LEC.value: + step["info"] = {"golden_verilog": str(golden)} self.workspace.flow.data = {"steps": steps} diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index c531aa62..39ca8e93 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -89,10 +89,20 @@ def _relation_with_skipped_steps( A ledger written under a wider policy (e.g. with the synthesis LEC enabled) stays runnable when the effective policy excludes those steps: the excluded entries are ignored for comparison and never - removed from the ledger. Returns "" when the ledger is not - compatible even after ignoring skipped steps. + removed from the ledger. An entry only counts as a skipped step when + its (name, tool) pair matches the canonical chain — a corrupted entry + (right name, wrong tool) is never silently ignored. Returns "" when + the ledger is not compatible even after ignoring skipped steps. """ - kept = [entry for entry in persisted if entry[0] not in set(skip)] + from chipcompiler.data.workspace import _canonical_rtl2gds_flow_entries + + canonical_tools = {name: tool for name, tool, _state in _canonical_rtl2gds_flow_entries()} + excluded = set(skip) + kept = [ + entry + for entry in persisted + if not (entry[0] in excluded and entry[1] == canonical_tools.get(entry[0])) + ] if kept == target: return "equal" if len(kept) < len(target) and target[: len(kept)] == kept: diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 2423e0b9..c4f70495 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -207,3 +207,33 @@ def _create(project_dir, pdk_root, run_id, states): return run_dir return _create + + +@pytest.fixture +def set_flow_preset(): + """Set [flow] preset in a project's ecc.toml.""" + + def _set(project_dir, preset): + toml_path = os.path.join(project_dir, "ecc.toml") + with open(toml_path) as f: + content = f.read() + content = content.replace('preset = "rtl2gds"', f'preset = "{preset}"') + with open(toml_path, "w") as f: + f.write(content) + + return _set + + +@pytest.fixture +def patch_all_flow_builders(): + """Patch every preset builder with a distinctive two-step stub chain.""" + + def _patch(monkeypatch): + markers = {} + for attr in ("build_rtl2gds_flow", "build_syn_sta_flow", "build_synthesis_lec_flow"): + steps = [("Synthesis", "yosys", "Unstart"), (attr, "ecc", "Unstart")] + markers[attr] = steps + monkeypatch.setattr(f"chipcompiler.rtl2gds.builder.{attr}", lambda steps=steps: steps) + return markers + + return _patch diff --git a/test/cli/commands/test_migrate.py b/test/cli/commands/test_migrate.py index b52991a2..32874ef8 100644 --- a/test/cli/commands/test_migrate.py +++ b/test/cli/commands/test_migrate.py @@ -450,70 +450,6 @@ def test_missing_flow_json_migrates_with_not_started_defaults( (workspace,) = _manifest(project_dir)["workspaces"] assert workspace["status"] == "not_started" - def test_persisted_skip_policy_carries_into_the_manifest_entry( - self, - tmp_path, - capsys, - create_cli_project, - minimal_ics55_pdk_factory, - create_legacy_workspace, - ): - from chipcompiler.data.workspace_config import save_workspace_config - - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - project_dir = create_cli_project(pdk_root=pdk_root) - run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) - # A declared policy (here: timing optimization) on the existing payload. - from chipcompiler.data.workspace_config import load_workspace_config - - payload = load_workspace_config(run_dir) - payload.pop("_flow", None) - assert save_workspace_config( - run_dir, - payload, - {"start": "Synthesis", "end": "postFloorplan", "skip_steps": ["TimingOpt"]}, - ) - - rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) - - assert rc == 0 - (workspace,) = _manifest(project_dir)["workspaces"] - # The persisted policy is normalized by save_workspace_config, so - # the manifest entry carries the canonical step value. - assert workspace["skip_steps"] == ["Timing optimization"] - - def test_undeclared_and_empty_skip_policies_stay_distinct_after_migration( - self, - tmp_path, - capsys, - create_cli_project, - minimal_ics55_pdk_factory, - create_legacy_workspace, - ): - from chipcompiler.data.workspace_config import ( - load_workspace_config, - save_workspace_config, - ) - - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - project_dir = create_cli_project(pdk_root=pdk_root) - absent_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) - empty_dir = create_legacy_workspace(project_dir, pdk_root, "exp2", ["Success", "Success"]) - for run_dir, section in ( - (empty_dir, {"start": "Synthesis", "end": "postFloorplan", "skip_steps": []}), - (absent_dir, {"start": "Synthesis", "end": "postFloorplan"}), - ): - payload = load_workspace_config(run_dir) - payload.pop("_flow", None) - assert save_workspace_config(run_dir, payload, section) - - rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) - - assert rc == 0 - entries = {entry["workspace_id"]: entry for entry in _manifest(project_dir)["workspaces"]} - assert "skip_steps" not in entries["exp1"] - assert entries["exp2"]["skip_steps"] == [] - def test_non_object_flow_json_is_blocked( self, tmp_path, diff --git a/test/cli/commands/test_migrate_skip_policy.py b/test/cli/commands/test_migrate_skip_policy.py new file mode 100644 index 00000000..8f21a54d --- /dev/null +++ b/test/cli/commands/test_migrate_skip_policy.py @@ -0,0 +1,80 @@ +"""Migration carries a workspace's persisted skip policy into its +manifest entry (moved out of test_migrate.py, over the size guideline). +""" + +import json +import os + +from chipcompiler.cli import main as cli_main + + +def _manifest(project_dir): + with open(os.path.join(project_dir, "project.json")) as f: + return json.load(f) + + +class TestMigrationSkipPolicy: + def test_persisted_skip_policy_carries_into_the_manifest_entry( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + from chipcompiler.data.workspace_config import ( + load_workspace_config, + save_workspace_config, + ) + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + # A declared policy (here: timing optimization) on the existing payload. + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config( + run_dir, + payload, + {"start": "Synthesis", "end": "postFloorplan", "skip_steps": ["TimingOpt"]}, + ) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc == 0 + (workspace,) = _manifest(project_dir)["workspaces"] + # The persisted policy is normalized by save_workspace_config, so + # the manifest entry carries the canonical step value. + assert workspace["skip_steps"] == ["Timing optimization"] + + def test_undeclared_and_empty_skip_policies_stay_distinct_after_migration( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + from chipcompiler.data.workspace_config import ( + load_workspace_config, + save_workspace_config, + ) + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + absent_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + empty_dir = create_legacy_workspace(project_dir, pdk_root, "exp2", ["Success", "Success"]) + for run_dir, section in ( + (empty_dir, {"start": "Synthesis", "end": "postFloorplan", "skip_steps": []}), + (absent_dir, {"start": "Synthesis", "end": "postFloorplan"}), + ): + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config(run_dir, payload, section) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc == 0 + entries = {entry["workspace_id"]: entry for entry in _manifest(project_dir)["workspaces"]} + assert "skip_steps" not in entries["exp1"] + assert entries["exp2"]["skip_steps"] == [] diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 3be0fd5d..27a52c66 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -8,24 +8,6 @@ from chipcompiler.engine import StepRunResult -def _set_flow_preset(project_dir, preset): - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace('preset = "rtl2gds"', f'preset = "{preset}"') - with open(toml_path, "w") as f: - f.write(content) - - -def _patch_all_flow_builders(monkeypatch): - markers = {} - for attr in ("build_rtl2gds_flow", "build_syn_sta_flow", "build_synthesis_lec_flow"): - steps = [("Synthesis", "yosys", "Unstart"), (attr, "ecc", "Unstart")] - markers[attr] = steps - monkeypatch.setattr(f"chipcompiler.rtl2gds.builder.{attr}", lambda steps=steps: steps) - return markers - - class TestRun: def test_run_calls_create_workspace(self, tmp_path, create_cli_project, flow_mocks): project_dir = create_cli_project() @@ -140,13 +122,20 @@ def test_run_preserves_final_records( class TestRunFlowPreset: def test_run_overwrite_rebuilds_flow_with_new_preset( - self, tmp_path, monkeypatch, create_cli_project, create_flow_json, flow_mocks + self, + tmp_path, + monkeypatch, + create_cli_project, + create_flow_json, + flow_mocks, + set_flow_preset, + patch_all_flow_builders, ): project_dir = create_cli_project() run_dir = os.path.join(project_dir, "default") create_flow_json(run_dir, profile="main") - _set_flow_preset(project_dir, "syn_sta") - markers = _patch_all_flow_builders(monkeypatch) + set_flow_preset(project_dir, "syn_sta") + markers = patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir, "--overwrite"]) @@ -154,10 +143,10 @@ def test_run_overwrite_rebuilds_flow_with_new_preset( assert flow_mocks.flow.instances[0].added_steps == markers["build_syn_sta_flow"] def test_run_preset_flag_overrides_toml( - self, tmp_path, monkeypatch, create_cli_project, flow_mocks + self, tmp_path, monkeypatch, create_cli_project, flow_mocks, patch_all_flow_builders ): project_dir = create_cli_project() - markers = _patch_all_flow_builders(monkeypatch) + markers = patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir, "--preset", "syn_sta"]) @@ -165,13 +154,13 @@ def test_run_preset_flag_overrides_toml( assert flow_mocks.flow.instances[0].added_steps == markers["build_syn_sta_flow"] def test_run_preset_flag_does_not_edit_toml( - self, tmp_path, monkeypatch, create_cli_project, flow_mocks + self, tmp_path, monkeypatch, create_cli_project, flow_mocks, patch_all_flow_builders ): project_dir = create_cli_project() toml_path = os.path.join(project_dir, "ecc.toml") with open(toml_path) as f: before = f.read() - _patch_all_flow_builders(monkeypatch) + patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir, "--preset", "syn_sta"]) @@ -180,10 +169,17 @@ def test_run_preset_flag_does_not_edit_toml( assert f.read() == before def test_run_preset_flag_rejects_unknown_preset( - self, tmp_path, capsys, monkeypatch, create_cli_project, flow_mocks, plain_records + self, + tmp_path, + capsys, + monkeypatch, + create_cli_project, + flow_mocks, + patch_all_flow_builders, + plain_records, ): project_dir = create_cli_project() - _patch_all_flow_builders(monkeypatch) + patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir, "--preset", "bogus", "--plain"]) diff --git a/test/cli/commands/test_run_skip_policy.py b/test/cli/commands/test_run_skip_policy.py index 66dc9ca0..2884bdad 100644 --- a/test/cli/commands/test_run_skip_policy.py +++ b/test/cli/commands/test_run_skip_policy.py @@ -13,24 +13,6 @@ from chipcompiler.cli import main as cli_main -def _set_flow_preset(project_dir, preset): - toml_path = os.path.join(project_dir, "ecc.toml") - with open(toml_path) as f: - content = f.read() - content = content.replace('preset = "rtl2gds"', f'preset = "{preset}"') - with open(toml_path, "w") as f: - f.write(content) - - -def _patch_all_flow_builders(monkeypatch): - markers = {} - for attr in ("build_rtl2gds_flow", "build_syn_sta_flow", "build_synthesis_lec_flow"): - steps = [("Synthesis", "yosys", "Unstart"), (attr, "ecc", "Unstart")] - markers[attr] = steps - monkeypatch.setattr(f"chipcompiler.rtl2gds.builder.{attr}", lambda steps=steps: steps) - return markers - - def _enable_lec_in(project_dir): """Append an explicit empty skip list — the only LEC enable path.""" toml_path = os.path.join(project_dir, "ecc.toml") @@ -50,15 +32,22 @@ def _enable_lec_in(project_dir): ], ) def test_run_dispatches_builder_for_preset( - tmp_path, monkeypatch, create_cli_project, flow_mocks, preset, builder_attr + tmp_path, + monkeypatch, + create_cli_project, + flow_mocks, + set_flow_preset, + patch_all_flow_builders, + preset, + builder_attr, ): project_dir = create_cli_project() - _set_flow_preset(project_dir, preset) + set_flow_preset(project_dir, preset) if preset == "synthesis_lec": # The preset needs the synthesis LEC the default policy skips; # an explicit empty skip list is the only enable path. _enable_lec_in(project_dir) - markers = _patch_all_flow_builders(monkeypatch) + markers = patch_all_flow_builders(monkeypatch) rc = cli_main.run(["run", "--project", project_dir]) @@ -67,10 +56,10 @@ def test_run_dispatches_builder_for_preset( def test_synthesis_lec_preset_conflicts_with_default_skip_policy( - tmp_path, capsys, create_cli_project, flow_mocks + tmp_path, capsys, create_cli_project, flow_mocks, set_flow_preset ): project_dir = create_cli_project() - _set_flow_preset(project_dir, "synthesis_lec") + set_flow_preset(project_dir, "synthesis_lec") # No skip_steps declared: the code default skips lec. rc = cli_main.run(["run", "--project", project_dir]) @@ -81,12 +70,12 @@ def test_synthesis_lec_preset_conflicts_with_default_skip_policy( def test_synthesis_lec_preset_conflict_never_fires_for_existing_ledger( - tmp_path, create_cli_project, flow_mocks + tmp_path, create_cli_project, flow_mocks, set_flow_preset ): from chipcompiler.data.workspace_config import save_workspace_config project_dir = create_cli_project() - _set_flow_preset(project_dir, "synthesis_lec") + set_flow_preset(project_dir, "synthesis_lec") run_dir = os.path.join(project_dir, "default") home = os.path.join(run_dir, "home") os.makedirs(home, exist_ok=True) diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 9a002d3c..01d07ba2 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -221,144 +221,6 @@ def test_create_workspace_persists_dynamic_flow_steps( assert all(step["peak memory (mb)"] == 0 for step in flow_data["steps"]) -def test_create_workspace_rejects_invalid_skip_steps_before_any_mutation( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - with pytest.raises(ValueError, match="skip_steps"): - create_workspace( - directory=workspace_dir, - origin_def="", - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={"start_step": "Synthesis", "end_step": "Harden", "skip_steps": "lec"}, - ) - - assert not workspace_dir.exists() - - -def test_create_workspace_default_policy_keeps_lec_out_of_the_ledger( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - """No declared policy: the synthesis LEC never enters the ledger, so - preFloorplan directly follows synthesis and consumes its outputs.""" - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - def_path = tmp_path / "gcd.def" - def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=workspace_dir, - origin_def=def_path, - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={"start_step": "Synthesis", "end_step": "preFloorplan"}, - ) - - flow_data = json_read(workspace_dir / "home" / "flow.json") - assert [step["name"] for step in flow_data["steps"]] == ["Synthesis", "preFloorplan"] - assert [step["tool"] for step in flow_data["steps"]] == ["yosys", "ecc"] - - -def test_create_workspace_explicit_empty_skip_enables_lec_in_the_ledger( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - """skip_steps = [] is the only LEC enable: the ledger carries the LEC - entry (with the golden netlist recorded) and preFloorplan still follows - the synthesis side of the chain.""" - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - def_path = tmp_path / "gcd.def" - def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=workspace_dir, - origin_def=def_path, - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={"start_step": "Synthesis", "end_step": "preFloorplan", "skip_steps": []}, - ) - - flow_data = json_read(workspace_dir / "home" / "flow.json") - assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ - ("Synthesis", "yosys"), - ("lec", "yosys_lec"), - ("preFloorplan", "ecc"), - ] - - -def test_create_workspace_skip_timing_opt_chains_route_after_legalization( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=workspace_dir, - origin_def="", - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={ - "start_step": "legalization", - "end_step": "route", - "skip_steps": ["TimingOpt"], - }, - ) - - flow_data = json_read(workspace_dir / "home" / "flow.json") - assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ - ("legalization", "dreamplace"), - ("route", "ecc"), - ] - - -def test_create_workspace_skip_post_route_lec_chains_drc_after_lvs( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - create_workspace( - directory=workspace_dir, - origin_def="", - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={ - "start_step": "lvs", - "end_step": "drc", - "skip_steps": ["postRouteLec"], - }, - ) - - flow_data = json_read(workspace_dir / "home" / "flow.json") - assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ - ("lvs", "ecc"), - ("drc", "ecc"), - ] - - def test_create_workspace_copies_external_lec_and_sta_inputs( tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters ): @@ -1780,29 +1642,3 @@ def test_create_workspace_pdk_overrides_typo_propagates( pdk_root=str(pdk_root), pdk_overrides={"dontuse": ["ICG*"]}, ) - - -def test_create_workspace_policy_only_config_persists_declared_policy( - tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters -): - """A policy-only flow config (no selected steps) still persists the - declared policy, so ledger-less rebuilds resolve the same chain.""" - pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") - netlist_path = tmp_path / "gcd.v" - netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") - - workspace_dir = tmp_path / "workspace" - workspace = create_workspace( - directory=workspace_dir, - origin_def="", - origin_verilog=netlist_path, - pdk="ics55", - parameters=deepcopy(default_ics55_parameters), - pdk_root=pdk_root, - flow_config={"skip_steps": []}, - ) - - assert workspace is not None - assert not (workspace_dir / "home" / "flow.json").exists() - loaded = load_workspace(str(workspace_dir)) - assert loaded.parameters.data["_flow"] == {"skip_steps": []} diff --git a/test/data/test_workspace_skip_policy.py b/test/data/test_workspace_skip_policy.py new file mode 100644 index 00000000..28d4be8a --- /dev/null +++ b/test/data/test_workspace_skip_policy.py @@ -0,0 +1,177 @@ +"""Skip-policy behavior exercised through data.create_workspace. + +Moved out of test_workspace.py (over the module-size guideline): each +test pins one ledger-level skip-policy contract — validation before +mutation, chaining under each policy, and policy-only persistence. +""" + +from copy import deepcopy + +import pytest + +from chipcompiler.data import create_workspace, load_workspace +from chipcompiler.utility import json_read + + +def test_create_workspace_rejects_invalid_skip_steps_before_any_mutation( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + with pytest.raises(ValueError, match="skip_steps"): + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "Harden", "skip_steps": "lec"}, + ) + + assert not workspace_dir.exists() + + +def test_create_workspace_default_policy_keeps_lec_out_of_the_ledger( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """No declared policy: the synthesis LEC never enters the ledger, so + preFloorplan directly follows synthesis and consumes its outputs.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + def_path = tmp_path / "gcd.def" + def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def=def_path, + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "preFloorplan"}, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [step["name"] for step in flow_data["steps"]] == ["Synthesis", "preFloorplan"] + assert [step["tool"] for step in flow_data["steps"]] == ["yosys", "ecc"] + + +def test_create_workspace_explicit_empty_skip_enables_lec_in_the_ledger( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """skip_steps = [] is the only LEC enable: the ledger carries the LEC + entry (with the golden netlist recorded) and preFloorplan still follows + the synthesis side of the chain.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + def_path = tmp_path / "gcd.def" + def_path.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def=def_path, + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"start_step": "Synthesis", "end_step": "preFloorplan", "skip_steps": []}, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("Synthesis", "yosys"), + ("lec", "yosys_lec"), + ("preFloorplan", "ecc"), + ] + + +def test_create_workspace_skip_timing_opt_chains_route_after_legalization( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={ + "start_step": "legalization", + "end_step": "route", + "skip_steps": ["TimingOpt"], + }, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("legalization", "dreamplace"), + ("route", "ecc"), + ] + + +def test_create_workspace_skip_post_route_lec_chains_drc_after_lvs( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={ + "start_step": "lvs", + "end_step": "drc", + "skip_steps": ["postRouteLec"], + }, + ) + + flow_data = json_read(workspace_dir / "home" / "flow.json") + assert [(step["name"], step["tool"]) for step in flow_data["steps"]] == [ + ("lvs", "ecc"), + ("drc", "ecc"), + ] + + +def test_create_workspace_policy_only_config_persists_declared_policy( + tmp_path, minimal_ics55_pdk_factory, default_ics55_parameters +): + """A policy-only flow config (no selected steps) still persists the + declared policy, so ledger-less rebuilds resolve the same chain.""" + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + netlist_path = tmp_path / "gcd.v" + netlist_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + + workspace_dir = tmp_path / "workspace" + workspace = create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=netlist_path, + pdk="ics55", + parameters=deepcopy(default_ics55_parameters), + pdk_root=pdk_root, + flow_config={"skip_steps": []}, + ) + + assert workspace is not None + assert not (workspace_dir / "home" / "flow.json").exists() + loaded = load_workspace(str(workspace_dir)) + assert loaded.parameters.data["_flow"] == {"skip_steps": []} From 1616cdef8fd20b64a2cb981286fd51a895c9c419 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 03:19:14 +0800 Subject: [PATCH 16/19] fix(cli): address whole-branch review round 2 findings - manifest loading now rejects a workspace range boundary the declared skip policy excludes (display name mapped to the canonical step), with regression coverage for boundary and inside-range cases - migration reads a workspace's params.toml strictly: only a missing config reads as 'no policy'; malformed, undecodable, or unreadable configs block that workspace's migration with a config-specific reason, covered by new tests - the flow_config selects-steps check lives once in effective_config and is imported by run_prepare instead of being duplicated - ProjectConfig declares _skip_steps_source as a typed field --- chipcompiler/cli/project/config.py | 3 ++ chipcompiler/cli/project/migrate_plan.py | 15 +++++-- chipcompiler/cli/project/run_prepare.py | 14 +------ chipcompiler/project/manifest.py | 30 +++++++++----- test/cli/commands/test_migrate_skip_policy.py | 39 +++++++++++++++++++ test/cli/project/test_skip_steps_config.py | 28 +++++++++++++ 6 files changed, 104 insertions(+), 25 deletions(-) diff --git a/chipcompiler/cli/project/config.py b/chipcompiler/cli/project/config.py index e432978c..9888ff46 100644 --- a/chipcompiler/cli/project/config.py +++ b/chipcompiler/cli/project/config.py @@ -50,6 +50,9 @@ class ProjectConfig: # ecc.toml): the workspace's own [flow] is then the run target source. manifest_driven: bool = False + # Winning skip_steps layer recorded by effective-config resolution + # ("project.json" / "ecc.toml"); None when never resolved. + _skip_steps_source: str | None = field(default=None, init=False, repr=False) _toml_error: str | None = field(default=None, init=False, repr=False) _param_errors: list[str] = field(default_factory=list, init=False, repr=False) _pdk_config_errors: list[str] = field(default_factory=list, init=False, repr=False) diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 27c3d774..223db29b 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -189,9 +189,10 @@ class _InvalidPersistedSkipSteps(ValueError): def _persisted_skip_steps(run_dir: str) -> tuple[str, ...] | None: """The workspace's declared ``[flow] skip_steps``; None when absent. - Read raw like the ledger above: the value was validated when written, - and a hand-broken one degrades to an undeclared policy (the default) - instead of poisoning the migrated manifest against ever loading. + Only a MISSING config reads as "no policy declared". A config that + exists but cannot be parsed/decoded/read is invalid input, never a + silent default: the caller blocks the workspace's migration with the + reason instead of dropping the user's policy. """ import tomllib @@ -199,8 +200,14 @@ def _persisted_skip_steps(run_dir: str) -> tuple[str, ...] | None: try: with open(config_path, "rb") as f: data = tomllib.load(f) - except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError): + except FileNotFoundError: return None + except tomllib.TOMLDecodeError as exc: + raise _InvalidPersistedSkipSteps(f"params.toml is malformed: {exc}") from None + except UnicodeDecodeError as exc: + raise _InvalidPersistedSkipSteps(f"params.toml is not valid UTF-8: {exc}") from None + except OSError as exc: + raise _InvalidPersistedSkipSteps(f"params.toml could not be read: {exc}") from None flow = data.get("flow") if not isinstance(flow, dict) or "skip_steps" not in flow: return None diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py index 1bc91afa..cfca549e 100644 --- a/chipcompiler/cli/project/run_prepare.py +++ b/chipcompiler/cli/project/run_prepare.py @@ -118,17 +118,6 @@ def _workspace_failed_result(run_name: str, run_dir: str, reason: str | None) -> return CommandResult.err([record]) -def _flow_config_selects_steps(flow_config) -> bool: - """Whether a creation flow config names steps (range or selection). - - A policy-only config (just ``skip_steps``) selects nothing and must not - mask a preset target. - """ - if not isinstance(flow_config, dict): - return False - return bool(flow_config.get("start_step")) or bool(flow_config.get("steps")) - - def _fresh_entry_step_name(cfg, flow_config) -> str | None: """The canonical first step a fresh workspace target will execute. @@ -248,6 +237,7 @@ def execute_fresh_run( resolve_rtl, to_parameters, ) + from chipcompiler.cli.project.effective_config import flow_config_selects_steps from chipcompiler.data import create_workspace from chipcompiler.data.parameter import save_parameter, update_parameters from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY @@ -451,7 +441,7 @@ def failed_workspace(reason: str | None) -> CommandResult: with open(provenance_path, "w") as _f: json.dump(cli_overrides, _f) - if not _flow_config_selects_steps(flow_config): + if not flow_config_selects_steps(flow_config): # CLI-born workspaces persist the named preset chain as # their target; a declared skip policy rides along, # normalized (validate_flow_config is the normalizer). diff --git a/chipcompiler/project/manifest.py b/chipcompiler/project/manifest.py index b66ba308..32d8f59f 100644 --- a/chipcompiler/project/manifest.py +++ b/chipcompiler/project/manifest.py @@ -79,6 +79,9 @@ "Harden": "Harden", } +# Display name -> canonical step value, for skip-policy boundary checks. +_MANIFEST_TO_CANONICAL_STEP = {v: k for k, v in _CANONICAL_TO_MANIFEST_STEP.items()} + _WORKSPACE_STATUSES = frozenset( {"success", "failed", "running", "in_progress", "not_started", "archived"} ) @@ -135,22 +138,24 @@ def _record(value: Any) -> dict: return value if isinstance(value, dict) else {} -def _workspace_skip_steps(source: dict, index: int) -> tuple[str, ...] | None: - """Validated workspaces[].skip_steps; None when the key is absent. +def _workspace_skip_steps(source: dict, index: int) -> tuple[tuple[str, ...] | None, set[str]]: + """Validated workspaces[].skip_steps and its canonical step set. - The declared spelling is kept verbatim (one normalizer exists, in the - skip resolver); only its validity is checked here so an invalid value - fails the whole manifest load before any registration or write. + Returns (declared spelling, canonical skipped values); the declared + spelling is None when the key is absent. The declared spelling is + kept verbatim (one normalizer exists, in the skip resolver); only its + validity is checked here so an invalid value fails the whole manifest + load before any registration or write. """ if "skip_steps" not in source: - return None + return None, set() from chipcompiler.rtl2gds import resolve_skip_steps try: - resolve_skip_steps({"skip_steps": source["skip_steps"]}) + canonical = set(resolve_skip_steps({"skip_steps": source["skip_steps"]})) except ValueError as exc: raise ManifestError(f"workspaces[{index}] {exc}") from None - return tuple(source["skip_steps"]) + return tuple(source["skip_steps"]), canonical def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace: @@ -178,6 +183,7 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani status = source.get("status") if not isinstance(status, str) or status not in _WORKSPACE_STATUSES: status = "not_started" + declared_skip, skipped = _workspace_skip_steps(source, index) start_step = _optional_str(source.get("start_step")) or "Synth" end_step = _optional_str(source.get("end_step")) or "Harden" start_step = _MANIFEST_STEP_ALIASES.get(start_step, start_step) @@ -187,6 +193,12 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani raise ManifestError( f"workspaces[{index}] {field_name} is not on the canonical flow chain: {step_name}" ) + for step_name, field_name in ((start_step, "start_step"), (end_step, "end_step")): + if _MANIFEST_TO_CANONICAL_STEP[step_name] in skipped: + raise ManifestError( + f"workspaces[{index}] {field_name} {step_name!r} is skipped by skip_steps " + f"and cannot bound the flow range" + ) if MANIFEST_FLOW_STEPS.index(start_step) > MANIFEST_FLOW_STEPS.index(end_step): raise ManifestError( f"workspaces[{index}] flow range is reversed: {start_step} -> {end_step}" @@ -198,7 +210,7 @@ def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> Mani end_step=end_step, status=status, parameter_patch=_record(source.get("parameter_patch")), - skip_steps=_workspace_skip_steps(source, index), + skip_steps=declared_skip, raw=dict(source), ) diff --git a/test/cli/commands/test_migrate_skip_policy.py b/test/cli/commands/test_migrate_skip_policy.py index 8f21a54d..4a5a8ce0 100644 --- a/test/cli/commands/test_migrate_skip_policy.py +++ b/test/cli/commands/test_migrate_skip_policy.py @@ -4,6 +4,7 @@ import json import os +from pathlib import Path from chipcompiler.cli import main as cli_main @@ -78,3 +79,41 @@ def test_undeclared_and_empty_skip_policies_stay_distinct_after_migration( entries = {entry["workspace_id"]: entry for entry in _manifest(project_dir)["workspaces"]} assert "skip_steps" not in entries["exp1"] assert entries["exp2"]["skip_steps"] == [] + + def test_malformed_params_toml_blocks_migration_instead_of_dropping_policy( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + Path(run_dir, "home", "params.toml").write_bytes(b"[flow\nskip_steps = [") + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc != 0 + assert os.path.exists(run_dir), "a blocked workspace must stay under runs/" + + def test_invalid_skip_steps_value_blocks_migration( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + # Write raw invalid policy bypassing save-time validation. + toml_path = Path(run_dir, "home", "params.toml") + toml_path.write_text(toml_path.read_text() + "\n[flow.skip_steps]\nroute = true\n") + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc != 0 + assert os.path.exists(run_dir) diff --git a/test/cli/project/test_skip_steps_config.py b/test/cli/project/test_skip_steps_config.py index 1b9aea8b..73c3c629 100644 --- a/test/cli/project/test_skip_steps_config.py +++ b/test/cli/project/test_skip_steps_config.py @@ -218,3 +218,31 @@ def test_pre_register_materializes_declared_skip_steps(tmp_path, monkeypatch): assert outcome == "registered" (entry,) = load_manifest(str(tmp_path)).workspaces assert entry.skip_steps == ("TimingOpt",) + + +class TestManifestSkipBoundary: + def test_skipped_manifest_boundary_fails_the_load(self, tmp_path): + _write_manifest( + tmp_path, + [_workspace(tmp_path, start_step="Synth", end_step="LEC", skip_steps=["LEC"])], + ) + + with pytest.raises(ManifestError, match="cannot bound the flow range"): + load_manifest(str(tmp_path)) + + def test_skipped_step_inside_the_manifest_range_is_fine(self, tmp_path): + _write_manifest( + tmp_path, + [ + _workspace( + tmp_path, + start_step="Synth", + end_step="PreFloorplan", + skip_steps=["LEC"], + ) + ], + ) + + (entry,) = load_manifest(str(tmp_path)).workspaces + + assert entry.skip_steps == ("LEC",) From 1d552c7ec08ca3b0a4f2c4f4c4f6d7849c169230 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 03:41:07 +0800 Subject: [PATCH 17/19] fix(engine): policy-aware migration contiguity and name-based target states - migration validates ledger contiguity against the canonical chain as filtered by the workspace's resolved skip policy, so ledgers legitimately omitting postRouteLec or Timing optimization migrate instead of being blocked as gapped - reconcile evaluates target-prefix states by matching target step names in the ledger rather than positional slicing, so a skipped entry before the boundary can no longer hide an unfinished in-range step; both behaviors carry regression tests --- chipcompiler/cli/project/migrate_plan.py | 40 ++++++--- chipcompiler/engine/reconcile.py | 26 +++--- test/cli/commands/test_migrate_skip_policy.py | 81 +++++++++++++++++++ test/engine/test_reconcile.py | 28 +++++++ 4 files changed, 152 insertions(+), 23 deletions(-) diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index 223db29b..fd827de8 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -158,22 +158,31 @@ def _read_flow_steps(run_dir: str) -> list[dict] | None: "after the transition period", category=None, ) -def _is_contiguous_flow(names: list[str]) -> bool: +def _is_contiguous_flow(names: list[str], skip: tuple[str, ...] = ()) -> bool: """The persisted step names must form one contiguous slice of the - canonical chain: anything else cannot be registered as a start..end - manifest range without lying about the ledger.""" + canonical chain (as filtered by the workspace's resolved skip policy): + anything else cannot be registered as a start..end manifest range + without lying about the ledger. + + A declared policy legitimately omits its skipped steps; without one, + both the full chain and the lec-less chain are accepted so ledgers + from either pre-policy era (LEC in-chain or reverted) still migrate. + """ from chipcompiler.data.workspace_config import canonical_flow_chain chain = canonical_flow_chain() - for start in range(len(chain) - len(names) + 1): - if chain[start : start + len(names)] == names: - return True - # Workspaces created before synthesis-level LEC was added omit only this - # newly inserted step; retain their migration compatibility. - legacy_chain = [name for name in chain if name != "lec"] - for start in range(len(legacy_chain) - len(names) + 1): - if legacy_chain[start : start + len(names)] == names: - return True + candidates = [chain] + if skip: + excluded = set(skip) + candidates.append([name for name in chain if name not in excluded]) + else: + # No declared policy: the default skips the synthesis LEC, and + # ledgers from before the skip mechanism existed include it. + candidates.append([name for name in chain if name != "lec"]) + for candidate in candidates: + for start in range(len(candidate) - len(names) + 1): + if candidate[start : start + len(names)] == names: + return True return False @@ -306,7 +315,12 @@ def plan_migration(project_dir: str) -> MigrationPlan: ) continue names = [str(step["name"]) for step in steps] - if names and not _is_contiguous_flow(names): + from chipcompiler.rtl2gds import resolve_skip_steps + + resolved_skip = resolve_skip_steps( + {"skip_steps": list(persisted_skip)} if persisted_skip is not None else None + ) + if names and not _is_contiguous_flow(names, skip=resolved_skip): blocked[run_id] = ( "legacy flow is not a contiguous slice of the canonical chain " f"({names[0]}..{names[-1]} with gaps); register it by hand" diff --git a/chipcompiler/engine/reconcile.py b/chipcompiler/engine/reconcile.py index 39ca8e93..87fd8f81 100644 --- a/chipcompiler/engine/reconcile.py +++ b/chipcompiler/engine/reconcile.py @@ -70,6 +70,20 @@ def _entry_names(entries: list[tuple[str, str]]) -> tuple[str, ...]: return tuple(name for name, _tool in entries) +def _target_step_states(flow_data: dict, target: list[tuple[str, str]]) -> set[str]: + """States of the ledger steps the target names, matched by step name. + + Name matching (not position slicing) keeps skipped ledger entries from + shifting which states fall inside the evaluated target range. + """ + states_by_name = { + str(step.get("name", "")): str(step.get("state", "")) + for step in flow_data.get("steps", []) + if isinstance(step, dict) + } + return {states_by_name.get(name, "") for name, _tool in target} + + def compare_flows(persisted: list[tuple[str, str]], target: list[tuple[str, str]]) -> str: """Pairwise (name, tool) comparison of persisted vs target step lists.""" if persisted == target: @@ -277,11 +291,7 @@ def _probe_workspace(workspace_dir: Path, target_section: dict | None): # run's business. from chipcompiler.data.types import FINISHED_STEP_STATES - target_states = { - str(step.get("state", "")) - for step in flow_data.get("steps", [])[: len(target)] - if isinstance(step, dict) - } + target_states = _target_step_states(flow_data, target) return ( ReconcileResult( outcome="no_op" if target_states <= FINISHED_STEP_STATES else "resume", @@ -439,11 +449,7 @@ def _apply_mutation(workspace_dir: Path, probe: ReconcileResult, context: dict) # The persisted flow already covers the target: no-op only # when every step within the requested target range finished. flow_data = _persisted_flow_data(workspace_dir, json_read) - target_states = { - str(step.get("state", "")) - for step in flow_data.get("steps", [])[: len(target)] - if isinstance(step, dict) - } + target_states = _target_step_states(flow_data, target) outcome = "no_op" if target_states <= FINISHED_STEP_STATES else "resume" else: flow_data = _persisted_flow_data(workspace_dir, json_read) diff --git a/test/cli/commands/test_migrate_skip_policy.py b/test/cli/commands/test_migrate_skip_policy.py index 4a5a8ce0..a20abd85 100644 --- a/test/cli/commands/test_migrate_skip_policy.py +++ b/test/cli/commands/test_migrate_skip_policy.py @@ -117,3 +117,84 @@ def test_invalid_skip_steps_value_blocks_migration( assert rc != 0 assert os.path.exists(run_dir) + + def test_ledgers_omitting_declared_skipped_steps_migrate( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + """A ledger legitimately omitting its declared skipped steps + (postRouteLec, Timing optimization) is contiguous under the policy + and must not be blocked as gapped.""" + import json + + from chipcompiler.data.workspace_config import ( + load_workspace_config, + save_workspace_config, + ) + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + + # Build a with-skip ledger directly: Synthesis..Harden minus the + # two skipped steps, matching the declared policy below. + from chipcompiler.rtl2gds.builder import build_rtl2gds_flow + + chain = [ + (step.value if hasattr(step, "value") else str(step), str(tool)) + for step, tool, _state in build_rtl2gds_flow() + ] + skipped = {"postRouteLec", "Timing optimization"} + kept = [(name, tool) for name, tool in chain if name not in skipped] + + rtl_path = os.path.join(project_dir, "rtl", "gcd.v") + os.makedirs(os.path.dirname(rtl_path), exist_ok=True) + with open(rtl_path, "w") as f: + f.write("module gcd(input clk); endmodule\n") + + run_dir = os.path.join(project_dir, "runs", "exp1") + from chipcompiler.data import create_workspace + + workspace = create_workspace( + directory=run_dir, + origin_def="", + origin_verilog=rtl_path, + pdk="ics55", + parameters={"pdk": "ics55", "design": "gcd", "top_module": "gcd", "clock": "clk"}, + pdk_root=str(pdk_root), + ) + assert workspace is not None + home = os.path.join(run_dir, "home") + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump( + { + "steps": [ + { + "name": name, + "tool": tool, + "state": "Success", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + for name, tool in kept + ] + }, + f, + ) + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config( + run_dir, + payload, + {"start": "Synthesis", "end": "Harden", "skip_steps": sorted(skipped)}, + ) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc == 0 + (entry,) = _manifest(project_dir)["workspaces"] + assert entry["skip_steps"] == ["Timing optimization", "postRouteLec"] diff --git a/test/engine/test_reconcile.py b/test/engine/test_reconcile.py index eaef9e1a..804ad03a 100644 --- a/test/engine/test_reconcile.py +++ b/test/engine/test_reconcile.py @@ -346,3 +346,31 @@ def test_declared_policy_extends_the_target_chain(self, tmp_path): assert result.outcome == "extended" names = [step["name"] for step in _flow_steps(workspace_dir)] assert names == [name for name, _tool in RTL2GDS_STEPS] + + +class TestTargetPrefixWithSkippedLedgerSteps: + def test_unfinished_in_range_step_resumes_despite_interspersed_skip(self, tmp_path): + """A skipped ledger entry before the target boundary must not shift + the state evaluation: an unfinished in-range step means resume, not + no_op.""" + ledger = RTL2GDS_STEPS # full chain including lec + target_end = next( + index for index, (name, _tool) in enumerate(RTL2GDS_STEPS) if name == "RCX" + ) + # Everything finished except filler: inside the target range by + # name, but shifted out of a positional slice by the skipped lec. + unfinished = next( + index for index, (name, _tool) in enumerate(RTL2GDS_STEPS) if name == "filler" + ) + states = ["Unstart" if index == unfinished else "Success" for index in range(len(ledger))] + assert unfinished < target_end + workspace_dir = _write_workspace( + tmp_path, ledger, states=states, flow_section={"preset": "rtl2gds"} + ) + + result = reconcile_workspace( + workspace_dir, + {"start": "Synthesis", "end": "RCX", "skip_steps": ["lec"]}, + ) + + assert result.outcome == "resume" From 46db65b0c5167aa7dc1f1bc987ddd068f1398543 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 15 Sep 2026 03:59:02 +0800 Subject: [PATCH 18/19] fix(cli): explicit empty skip policy is authoritative for migration contiguity _is_contiguous_flow now distinguishes an undeclared policy (None: the full chain and the legacy lec-less chain both migrate) from an explicit declaration (authoritative: only the full chain and its policy-filtered form are accepted), so skip_steps = [] with a LEC-less ledger is blocked instead of producing a manifest entry the next run cannot reconcile --- chipcompiler/cli/project/migrate_plan.py | 32 ++++++++++------ test/cli/commands/test_migrate_skip_policy.py | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/chipcompiler/cli/project/migrate_plan.py b/chipcompiler/cli/project/migrate_plan.py index fd827de8..df0cd988 100644 --- a/chipcompiler/cli/project/migrate_plan.py +++ b/chipcompiler/cli/project/migrate_plan.py @@ -158,27 +158,31 @@ def _read_flow_steps(run_dir: str) -> list[dict] | None: "after the transition period", category=None, ) -def _is_contiguous_flow(names: list[str], skip: tuple[str, ...] = ()) -> bool: +def _is_contiguous_flow(names: list[str], skip: tuple[str, ...] | None) -> bool: """The persisted step names must form one contiguous slice of the canonical chain (as filtered by the workspace's resolved skip policy): anything else cannot be registered as a start..end manifest range without lying about the ledger. - A declared policy legitimately omits its skipped steps; without one, - both the full chain and the lec-less chain are accepted so ledgers - from either pre-policy era (LEC in-chain or reverted) still migrate. + *skip* is None when the workspace declared no policy: both the full + chain and the lec-less chain are then accepted so ledgers from either + pre-policy era (LEC in-chain or reverted) still migrate. An explicit + policy (even an empty one) is authoritative: only its filtered chain + (plus the unfiltered chain, for ledgers written before the policy was + applied) is accepted. """ from chipcompiler.data.workspace_config import canonical_flow_chain chain = canonical_flow_chain() candidates = [chain] - if skip: - excluded = set(skip) - candidates.append([name for name in chain if name not in excluded]) - else: - # No declared policy: the default skips the synthesis LEC, and - # ledgers from before the skip mechanism existed include it. + if skip is None: + # Undeclared: the default skips the synthesis LEC, and ledgers from + # before the skip mechanism existed include it. candidates.append([name for name in chain if name != "lec"]) + else: + excluded = set(skip) + if excluded: + candidates.append([name for name in chain if name not in excluded]) for candidate in candidates: for start in range(len(candidate) - len(names) + 1): if candidate[start : start + len(names)] == names: @@ -317,8 +321,12 @@ def plan_migration(project_dir: str) -> MigrationPlan: names = [str(step["name"]) for step in steps] from chipcompiler.rtl2gds import resolve_skip_steps - resolved_skip = resolve_skip_steps( - {"skip_steps": list(persisted_skip)} if persisted_skip is not None else None + # None keeps the era tolerance for an UNDECLARED policy; an + # explicit policy (even []) is authoritative for contiguity. + resolved_skip = ( + resolve_skip_steps({"skip_steps": list(persisted_skip)}) + if persisted_skip is not None + else None ) if names and not _is_contiguous_flow(names, skip=resolved_skip): blocked[run_id] = ( diff --git a/test/cli/commands/test_migrate_skip_policy.py b/test/cli/commands/test_migrate_skip_policy.py index a20abd85..1c8d02fd 100644 --- a/test/cli/commands/test_migrate_skip_policy.py +++ b/test/cli/commands/test_migrate_skip_policy.py @@ -198,3 +198,40 @@ def test_ledgers_omitting_declared_skipped_steps_migrate( assert rc == 0 (entry,) = _manifest(project_dir)["workspaces"] assert entry["skip_steps"] == ["Timing optimization", "postRouteLec"] + + def test_explicit_empty_policy_with_lec_less_ledger_is_blocked( + self, + tmp_path, + capsys, + create_cli_project, + minimal_ics55_pdk_factory, + create_legacy_workspace, + ): + """Explicit skip_steps = [] is authoritative: a LEC-less ledger is + NOT accepted by the legacy-era tolerance, because the declared + policy says the LEC should have run.""" + from chipcompiler.data.workspace_config import ( + load_workspace_config, + save_workspace_config, + ) + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + project_dir = create_cli_project(pdk_root=pdk_root) + run_dir = create_legacy_workspace(project_dir, pdk_root, "exp1", ["Success", "Success"]) + # Rewrite the ledger WITHOUT the LEC, then declare skip_steps = []. + ledger_path = Path(run_dir, "home", "flow.json") + ledger = json.loads(ledger_path.read_text()) + ledger["steps"] = [step for step in ledger["steps"] if step["name"] != "lec"] + ledger_path.write_text(json.dumps(ledger)) + payload = load_workspace_config(run_dir) + payload.pop("_flow", None) + assert save_workspace_config( + run_dir, + payload, + {"start": "Synthesis", "end": "postFloorplan", "skip_steps": []}, + ) + + rc = cli_main.run(["migrate", "--project", project_dir, "--yes"]) + + assert rc != 0 + assert os.path.exists(run_dir), "a blocked workspace must stay under runs/" From efd39cdb84fbaf495d1e1ad39c0ade8c8f350836 Mon Sep 17 00:00:00 2001 From: Qiming Chu Date: Tue, 15 Sep 2026 18:02:28 +0800 Subject: [PATCH 19/19] fix: adapt main-side code to skippable step enums and step module consolidation Adaptations for the latest main (9081182) that have no counterpart in the branch history: repoint STEP_DIRECTORIES imports in analysis/qor/loader.py and engine/analysis.py to data.step (data.step_dirs was merged into step.py), and reference SkippableStepEnum.TIMING_OPT in the sizer and step-storage-name tests. --- chipcompiler/analysis/qor/loader.py | 2 +- chipcompiler/engine/analysis.py | 3 +-- test/data/test_step_storage_name.py | 6 +++--- test/tools/ecc_sizer/test_runner.py | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/chipcompiler/analysis/qor/loader.py b/chipcompiler/analysis/qor/loader.py index e774f40d..78fba9c6 100644 --- a/chipcompiler/analysis/qor/loader.py +++ b/chipcompiler/analysis/qor/loader.py @@ -12,7 +12,7 @@ from chipcompiler.analysis.qor.metric_registry import SCORED_STEP_VALUES from chipcompiler.data import StateEnum, StepEnum -from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.data.step import STEP_DIRECTORIES from chipcompiler.tools.ecc.sta_qor import ( POST_SYNTHESIS_STA_CORNER, STA_POWER_SUMMARY_FILENAME, diff --git a/chipcompiler/engine/analysis.py b/chipcompiler/engine/analysis.py index 06ddc03e..f629f092 100644 --- a/chipcompiler/engine/analysis.py +++ b/chipcompiler/engine/analysis.py @@ -3,8 +3,7 @@ from pathlib import Path from typing import Any, TypeGuard -from chipcompiler.data.step import step_storage_name -from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.data.step import STEP_DIRECTORIES, step_storage_name from chipcompiler.engine.qor_scoring import DIMENSION_WEIGHTS from chipcompiler.tools.ecc.sta_qor import STA_POWER_REPORT_FILENAME, STA_REPORT_FILENAMES from chipcompiler.utility import JsonReadError, file_digest, json_read_strict diff --git a/test/data/test_step_storage_name.py b/test/data/test_step_storage_name.py index 1785b1ad..8157468d 100644 --- a/test/data/test_step_storage_name.py +++ b/test/data/test_step_storage_name.py @@ -1,7 +1,7 @@ -from chipcompiler.data import StepEnum, step_storage_name +from chipcompiler.data import SkippableStepEnum, StepEnum, step_storage_name def test_step_storage_name_sanitizes_sizer_timing_opt(): - assert step_storage_name(StepEnum.TIMING_OPT.value, "sizer") == "timing_optimization" - assert step_storage_name(StepEnum.TIMING_OPT.value, "Sizer") == "timing_optimization" + assert step_storage_name(SkippableStepEnum.TIMING_OPT.value, "sizer") == "timing_optimization" + assert step_storage_name(SkippableStepEnum.TIMING_OPT.value, "Sizer") == "timing_optimization" assert step_storage_name(StepEnum.FLOORPLAN.value, "ecc") == StepEnum.FLOORPLAN.value diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 0c7fee72..d3ce4088 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -178,7 +178,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_tool_is_signal_terminated( workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), ) @@ -223,7 +223,7 @@ def test_sizer_runner_reports_plain_exit_code_without_signal_or_fatal_line( workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, + step_name=SkippableStepEnum.TIMING_OPT.value, input_def=Path("input.def"), input_verilog=Path("input.v"), )