From 6d8843f88fa12aefe83e2a1b7175b72d18fe464c Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 20:10:09 +0800 Subject: [PATCH 01/90] feat(tools): allow legalize-only DreamPlace for Timing Opt Lift the legalization owner check so Timing Opt can reuse DreamPlace legalize-only, load ECC from explicit DEF/Verilog, and skip the pre-sizer input DB. --- chipcompiler/tools/ecc/runner.py | 31 ++-- chipcompiler/tools/ecc_dreamplace/__init__.py | 3 +- chipcompiler/tools/ecc_dreamplace/module.py | 16 +- chipcompiler/tools/ecc_dreamplace/runner.py | 61 +++++++ test/tools/ecc/test_runner.py | 49 ++++++ test/tools/ecc_dreamplace/test_module.py | 159 +++++++++++++++++- 6 files changed, 302 insertions(+), 17 deletions(-) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 0583e0149..d69f3a276 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -182,8 +182,18 @@ def _existing_input_path(path: Path | None) -> str | None: return None -def create_db_engine(workspace: Workspace, step: WorkspaceStep) -> ECCToolsModule: - """""" +def create_db_engine( + workspace: Workspace, + step: WorkspaceStep, + *, + source_def: Path | None = None, + source_verilog: Path | None = None, + skip_input_db: bool = False, +) -> ECCToolsModule | None: + """Load an ECC engine from the step input, or from explicit design files.""" + + def_source = source_def if source_def is not None else step.input.def_ + verilog_source = source_verilog if source_verilog is not None else step.input.verilog def load_data(): ecc_module = ECCToolsModule() @@ -216,12 +226,6 @@ def load_data(): return None def load_design(): - def def_exist() -> str | None: - return _existing_input_path(step.input.def_) - - def verilog_exist() -> str | None: - return _existing_input_path(step.input.verilog) - ecc_module = ECCToolsModule() ecc_module.init_config( @@ -234,8 +238,8 @@ def verilog_exist() -> str | None: ecc_module.init_lefs(workspace.pdk.lefs) # if db def exist, read db def - def_path = def_exist() - verilog_path = verilog_exist() + def_path = _existing_input_path(def_source) + verilog_path = _existing_input_path(verilog_source) if step.name == StepEnum.LVS.value: if def_path is None: @@ -258,14 +262,15 @@ def is_enable_setup(): return False return ( - _existing_input_path(step.input.def_) is not None - or _existing_input_path(step.input.verilog) is not None + _existing_input_path(def_source) is not None + or _existing_input_path(verilog_source) is not None ) if not is_eda_exist() or not is_enable_setup(): return None + skip_db = skip_input_db or step.name == StepEnum.LVS.value try: - ecc_module = None if step.name == StepEnum.LVS.value else load_data() + ecc_module = None if skip_db else load_data() if ecc_module is None: ecc_module = load_design() except Exception as e: diff --git a/chipcompiler/tools/ecc_dreamplace/__init__.py b/chipcompiler/tools/ecc_dreamplace/__init__.py index 547de8435..f601af04b 100644 --- a/chipcompiler/tools/ecc_dreamplace/__init__.py +++ b/chipcompiler/tools/ecc_dreamplace/__init__.py @@ -1,6 +1,6 @@ from .builder import build_step, build_step_config, build_step_space from .metrics import build_step_metrics -from .runner import run_step +from .runner import legalize_layout, run_step from .service import get_step_info from .utility import is_eda_exist @@ -11,5 +11,6 @@ "build_step_space", "get_step_info", "is_eda_exist", + "legalize_layout", "run_step", ] diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index e35eefc9a..d5433333e 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -11,6 +11,13 @@ 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, + } +) + class DreamplaceModule: def __init__( @@ -64,13 +71,18 @@ def _log_path(self, *, legalize_only: bool) -> str: log_name = "dreamplace_legalization.log" if legalize_only else "dreamplace_placement.log" return os.path.join(self.result_dir, log_name) + def _file_handler_path(self, *, legalize_only: bool) -> str: + if legalize_only and self.step.name != StepEnum.LEGALIZATION.value: + return self._log_path(legalize_only=True) + return str(self.step.log.file or self._log_path(legalize_only=legalize_only)) + @contextmanager def _configure_root_logging(self, *, legalize_only: bool): root_logger = logging.getLogger() original_handlers = root_logger.handlers[:] original_level = root_logger.level - log_file = self.step.log.file or self._log_path(legalize_only=legalize_only) + log_file = self._file_handler_path(legalize_only=legalize_only) os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) formatter = logging.Formatter("[%(levelname)-7s] %(message)s") @@ -117,7 +129,7 @@ def run_placement(self) -> bool: return self._run(legalize_only=False) def run_legalization(self) -> bool: - if self.step.name != StepEnum.LEGALIZATION.value: + if self.step.name not in _LEGALIZE_OWNERS: return False return self._run(legalize_only=True) diff --git a/chipcompiler/tools/ecc_dreamplace/runner.py b/chipcompiler/tools/ecc_dreamplace/runner.py index f1cbe25e9..6842d608e 100644 --- a/chipcompiler/tools/ecc_dreamplace/runner.py +++ b/chipcompiler/tools/ecc_dreamplace/runner.py @@ -1,5 +1,7 @@ #!/usr/bin/env python +from pathlib import Path + from chipcompiler.data import EccStep, StateEnum, StepEnum, Workspace from chipcompiler.tools.ecc import EccSubFlow, EccSubFlowEnum, ECCToolsModule from chipcompiler.tools.ecc import runner as ecc_runner @@ -130,3 +132,62 @@ def run_legalization( run_analysis(workspace=workspace, step=step, subflow=sub_flow) return reslut + + +def legalize_layout( + workspace: Workspace, + owner_step: EccStep, + input_def: Path | None, + input_verilog: Path | None, +) -> ECCToolsModule | None: + """Legalize a layout for an owning step without owning that step's subflow.""" + import logging + + logger = logging.getLogger(__name__) + if not is_eda_exist(): + logger.error( + "DreamPlace tools not available for inner legalization of %s", + owner_step.name, + ) + return None + + if not workspace.config.get("dreamplace"): + logger.error( + "DreamPlace config is missing for inner legalization of %s", + owner_step.name, + ) + return None + + ecc_module = ecc_runner.create_db_engine( + workspace, + owner_step, + source_def=input_def, + source_verilog=input_verilog, + skip_input_db=True, + ) + if ecc_module is None: + logger.error( + "Failed to rebuild ECC database for inner legalization of %s", + owner_step.name, + ) + return None + + keep_engine = False + try: + dreamplace_module = DreamplaceModule( + workspace=workspace, + step=owner_step, + ecc_module=ecc_module, + input_def=input_def, + input_verilog=input_verilog, + output_def=None, + output_verilog=None, + ) + if not dreamplace_module.run_legalization(): + logger.error("DreamPlace legalization failed for %s", owner_step.name) + return None + keep_engine = True + return ecc_module + finally: + if not keep_engine: + ecc_module.close() diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 829d4d0a0..7f148b6b6 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -53,6 +53,9 @@ def read_def(self, path): def read_lvs_verilog(self, path, top_module): self.calls.append(("read_lvs_verilog", path, top_module)) + def close(self): + self.calls.append(("close",)) + class FakeSynthesisStaModule: def __init__(self): @@ -225,6 +228,52 @@ def test_create_db_engine_uses_def_input_for_lvs_even_when_db_exists(tmp_path, m assert not any(call[0] == "read_lvs_verilog" for call in module.calls) +def test_create_db_engine_skip_input_db_reads_explicit_sources(tmp_path, monkeypatch): + step_def = tmp_path / "step" / "old.def" + staging_def = tmp_path / "data" / "to" / "sizer.def.gz" + staging_verilog = tmp_path / "data" / "to" / "sizer.v.gz" + step_def.parent.mkdir() + staging_def.parent.mkdir(parents=True) + step_def.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + staging_def.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + staging_verilog.write_text("module gcd; endmodule\n") + + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + pdk=PDK(tech=tmp_path / "tech.lef", lefs=[tmp_path / "std.lef"]), + config={"db": tmp_path / "config" / "db_ecc.json"}, + ) + step = EccStep( + name=StepEnum.TIMING_OPT.value, + input=StepInput( + def_=step_def, + verilog=tmp_path / "step" / "old.v", + db=tmp_path / "input_db", + ), + data=EccData(dir=tmp_path / "timing_optimization_sizer" / "data"), + feature=EccFeature(dir=tmp_path / "timing_optimization_sizer" / "feature"), + ) + FakeEccModule.instances = [] + monkeypatch.setattr(ecc_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(ecc_runner, "ECCToolsModule", FakeEccModule) + monkeypatch.setattr(FakeEccModule, "is_db_data_exists", lambda self, path: True) + monkeypatch.setattr(FakeEccModule, "load_data", lambda self, path: True) + + module = ecc_runner.create_db_engine( + workspace, + step, + source_def=staging_def, + source_verilog=staging_verilog, + skip_input_db=True, + ) + + assert module is FakeEccModule.instances[-1] + assert not any(call[0] == "load_data" for call in module.calls) + assert ("read_def", str(staging_def)) in module.calls + assert not any(call[0] == "read_def" and call[1] == str(step_def) for call in module.calls) + + def test_run_cts_merges_structured_timing_into_step_feature(tmp_path, monkeypatch): workspace = Workspace( directory=tmp_path, diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index aca5aefe4..9117a4b6c 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -1,6 +1,7 @@ +from pathlib import Path from types import SimpleNamespace -from chipcompiler.data import EccData, EccStep, OriginDesign, StepEnum, Workspace +from chipcompiler.data import EccData, EccStep, LogPaths, OriginDesign, StepEnum, Workspace from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule from chipcompiler.tools.ecc_dreamplace.service import get_step_info from chipcompiler.utility import json_write @@ -104,3 +105,159 @@ def test_dreamplace_step_info_stringifies_path_config(tmp_path): assert get_step_info(workspace, step, "config") == { "config": str(workspace.config["dreamplace"]), } + + +def _module_for_owner(tmp_path, step_name: str) -> DreamplaceModule: + config_path = tmp_path / "dreamplace_ecc.json" + json_write(config_path, {}) + workspace = Workspace( + directory=str(tmp_path / "workspace"), + design=OriginDesign(name="gcd"), + config={"dreamplace": config_path}, + ) + result_dir = tmp_path / "data" / "to" + step = EccStep( + name=step_name, + data=EccData(dir=tmp_path / "data", steps={step_name: result_dir}), + log=LogPaths(file=tmp_path / "step.log"), + ) + return DreamplaceModule( + workspace=workspace, + step=step, + ecc_module=None, + input_def=tmp_path / "input.def", + input_verilog=tmp_path / "input.v", + output_def=tmp_path / "output.def", + output_verilog=tmp_path / "output.v", + ) + + +def test_run_legalization_allows_timing_opt_and_legalization_owners(tmp_path, monkeypatch): + seen: list[str] = [] + + def fake_run(self, *, legalize_only: bool) -> bool: + seen.append(self.step.name) + assert legalize_only is True + return True + + 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) + 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] + + +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) + + assert legalization._file_handler_path(legalize_only=True) == str(tmp_path / "step.log") + assert timing_opt._file_handler_path(legalize_only=True) == str( + Path(timing_opt.result_dir) / "dreamplace_legalization.log" + ) + + +def test_dreamplace_run_step_ignores_timing_opt(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner + + monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(dreamplace_runner, "run_placement", lambda **kwargs: True) + 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) + + assert dreamplace_runner.run_step(workspace, step) is False + + +def test_legalize_layout_rebuilds_from_sources_and_closes_on_failure(tmp_path, monkeypatch): + 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) + staging_def = tmp_path / "sizer.def.gz" + staging_verilog = tmp_path / "sizer.v.gz" + created = [] + closed = [] + + class LocalEcc: + def close(self): + closed.append(True) + + def fake_create_db_engine( + workspace, + owner_step, + *, + source_def=None, + source_verilog=None, + skip_input_db=False, + ): + created.append((source_def, source_verilog, skip_input_db, owner_step.name, workspace)) + return LocalEcc() + + monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(dreamplace_runner.ecc_runner, "create_db_engine", fake_create_db_engine) + monkeypatch.setattr(DreamplaceModule, "run_legalization", lambda self: False) + + assert ( + dreamplace_runner.legalize_layout( + module.workspace, + module.step, + staging_def, + staging_verilog, + ) + is None + ) + assert created == [ + (staging_def, staging_verilog, True, StepEnum.TIMING_OPT.value, module.workspace) + ] + assert closed == [True] + + +def test_legalize_layout_returns_none_without_dreamplace_config(tmp_path, monkeypatch): + 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) + monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) + + assert ( + dreamplace_runner.legalize_layout( + workspace, + step, + tmp_path / "sizer.def.gz", + tmp_path / "sizer.v.gz", + ) + is None + ) + + +def test_legalize_layout_returns_engine_when_legalize_succeeds(tmp_path, monkeypatch): + 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) + engine = SimpleNamespace(close=lambda: (_ for _ in ()).throw(AssertionError("closed"))) + + monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr( + dreamplace_runner.ecc_runner, + "create_db_engine", + lambda *args, **kwargs: engine, + ) + monkeypatch.setattr(DreamplaceModule, "run_legalization", lambda self: True) + + assert ( + dreamplace_runner.legalize_layout( + module.workspace, + module.step, + tmp_path / "sizer.def.gz", + tmp_path / "sizer.v.gz", + ) + is engine + ) From 9bc20477dc3c5fb79e87811e9328f1e748c4fce1 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 20:12:49 +0800 Subject: [PATCH 02/90] feat(tools): legalize Sizer output inside Timing Opt Stage Sizer DEF/Verilog under data/to, rebuild ECC from those files, run DreamPlace legalize-only, and publish only the post-legalize layout. Drop the cached EngineDB after any sizer terminal state. --- chipcompiler/data/workspace/layout.py | 3 +- chipcompiler/engine/flow.py | 3 +- chipcompiler/tools/ecc_sizer/__init__.py | 10 +- chipcompiler/tools/ecc_sizer/builder.py | 26 ++- chipcompiler/tools/ecc_sizer/runner.py | 99 +++++++-- chipcompiler/tools/ecc_sizer/subflow.py | 22 +- docs/architecture.md | 4 +- test/data/test_workspace_layout.py | 4 +- test/tools/ecc_sizer/test_module.py | 67 +++++- test/tools/ecc_sizer/test_runner.py | 253 ++++++++++++++++++++++- 10 files changed, 438 insertions(+), 53 deletions(-) diff --git a/chipcompiler/data/workspace/layout.py b/chipcompiler/data/workspace/layout.py index aa36b843a..03efaa088 100644 --- a/chipcompiler/data/workspace/layout.py +++ b/chipcompiler/data/workspace/layout.py @@ -38,7 +38,8 @@ class OutputPaths: json: Path | None = None image: Path | None = None # Part of the cross-tool read contract (def/verilog/db): a Path for - # place-and-route steps, `""` for sizer, and None for synthesis. + # place-and-route steps, `""` for a step that publishes no DB, and None + # for synthesis. db: Path | str | None = None diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index ad108c9c2..d1e7d6c9a 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -410,7 +410,8 @@ def init_db_engine(self) -> bool: return self.engine_db.create_db_engine(step=workspace_step) def clear_db_engine_after_step(self, workspace_step: WorkspaceStep, state: StateEnum) -> None: - if workspace_step.tool == "sizer" and state == StateEnum.Success: + _ = state + if workspace_step.tool == "sizer": engine_db = self.engine_db self.engine_db = None if engine_db is not None: diff --git a/chipcompiler/tools/ecc_sizer/__init__.py b/chipcompiler/tools/ecc_sizer/__init__.py index 947c65d91..4aa46ea96 100644 --- a/chipcompiler/tools/ecc_sizer/__init__.py +++ b/chipcompiler/tools/ecc_sizer/__init__.py @@ -1,4 +1,10 @@ -from .builder import build_step, build_step_config, build_step_space +from .builder import ( + build_step, + build_step_config, + build_step_space, + sizer_staging_def, + sizer_staging_verilog, +) from .runner import run_step from .service import get_step_info from .utility import ( @@ -20,4 +26,6 @@ "is_eda_exist", "is_sizer_runtime_exist", "run_step", + "sizer_staging_def", + "sizer_staging_verilog", ] diff --git a/chipcompiler/tools/ecc_sizer/builder.py b/chipcompiler/tools/ecc_sizer/builder.py index 5caed3240..681f90be4 100644 --- a/chipcompiler/tools/ecc_sizer/builder.py +++ b/chipcompiler/tools/ecc_sizer/builder.py @@ -9,6 +9,9 @@ from .utility import find_sizer_root +SIZER_STAGING_DEF_NAME = "sizer.def.gz" +SIZER_STAGING_VERILOG_NAME = "sizer.v.gz" + def build_step( workspace: Workspace, @@ -41,11 +44,6 @@ def build_step( tool="sizer", step_directory=step_directory, ) - step.output.db = "" - # Sizer produces no geometry snapshot; leave the destination undeclared so - # it is not part of this step's success contract (see EngineFlow.check_step_result). - step.output.geometry = None - step.output.geometry_manifest = None script_dir = step.script.dir or step_directory / "script" step.script.sizer_env = script_dir / f"{workspace.design.name}.env_file" step.script.sizer_cmd = script_dir / f"{workspace.design.name}.cmd_file" @@ -117,6 +115,20 @@ def _append_route_layer_options(command: cmdfile.CommandFile, workspace: Workspa command.option("max_route_layer", top) +def sizer_staging_def(step: EccStep) -> Path: + workdir = step.data.workdir_for(step.name) + if workdir is None: + raise ValueError("sizer step is missing a Timing optimization workdir") + return Path(workdir) / SIZER_STAGING_DEF_NAME + + +def sizer_staging_verilog(step: EccStep) -> Path: + workdir = step.data.workdir_for(step.name) + if workdir is None: + raise ValueError("sizer step is missing a Timing optimization workdir") + return Path(workdir) / SIZER_STAGING_VERILOG_NAME + + def _cmd_text(workspace: Workspace, step: EccStep) -> str: output_dir = step.data.workdir_for(step.name) or "" command = cmdfile.CommandFile(prefix="-", dialect=cmdfile.PLAIN_DIALECT) @@ -150,12 +162,12 @@ def _cmd_text(workspace: Workspace, step: EccStep) -> str: command.option("outputPath", ".") command.option( "def_out_path", - os.path.relpath(step.output.def_ or "", output_dir), + os.path.relpath(sizer_staging_def(step), output_dir), value_type=cmdfile.ValueType.PATH, ) command.option( "verilog_out_path", - os.path.relpath(step.output.verilog or "", output_dir), + os.path.relpath(sizer_staging_verilog(step), output_dir), value_type=cmdfile.ValueType.PATH, ) _append_route_layer_options(command, workspace) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index 046cdb2da..0eab82f48 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -1,34 +1,76 @@ import logging import os +import shutil import subprocess +from pathlib import Path -from chipcompiler.data import EccStep, StateEnum, Workspace +from chipcompiler.data import EccOutput, EccStep, StateEnum, Workspace +from chipcompiler.tools.ecc import runner as ecc_runner +from chipcompiler.tools.ecc_dreamplace.runner import legalize_layout +from chipcompiler.tools.ecc_dreamplace.utility import is_eda_exist as is_dreamplace_exist +from .builder import sizer_staging_def, sizer_staging_verilog from .subflow import SizerSubFlow, SizerSubFlowEnum from .utility import get_sizer_command, is_eda_exist, is_sizer_runtime_exist logger = logging.getLogger(__name__) -def _has_required_outputs(step: EccStep) -> bool: - return os.path.exists(step.output.def_ or "") and os.path.exists(step.output.verilog or "") +def _has_staging_outputs(step: EccStep) -> bool: + return os.path.exists(sizer_staging_def(step)) and os.path.exists(sizer_staging_verilog(step)) + + +def _published_paths(step: EccStep) -> list[Path]: + output = step.output + if not isinstance(output, EccOutput): + return [] + + paths: list[Path] = [] + for value in ( + output.def_, + output.verilog, + output.gds, + output.db, + output.geometry, + output.geometry_manifest, + ): + if value: + paths.append(Path(value)) + return paths + + +def _delete_published_outputs(step: EccStep) -> None: + for path in _published_paths(step): + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) def run_step( workspace: Workspace, step: EccStep, - ecc_module=None, + ecc_module: object | None = None, ) -> StateEnum: del ecc_module sub_flow = SizerSubFlow(workspace=workspace, workspace_step=step) run_sizer_step = SizerSubFlowEnum.run_sizer.value + run_legalization_step = SizerSubFlowEnum.run_legalization.value + save_data_step = SizerSubFlowEnum.save_data.value + + _delete_published_outputs(step) if not is_eda_exist() or not is_sizer_runtime_exist(): logger.error("Sizer tools not available for step %s", step.name) sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Invalid) return StateEnum.Invalid + if not is_dreamplace_exist(): + logger.error("DreamPlace tools not available for inner legalization of %s", step.name) + sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Invalid) + return StateEnum.Invalid + env_path = step.script.sizer_env or "" cmd_path = step.script.sizer_cmd or "" if not os.path.exists(env_path) or not os.path.exists(cmd_path): @@ -57,14 +99,43 @@ def run_step( check=False, ) - if result.returncode == 0 and _has_required_outputs(step): - sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Success) - return StateEnum.Success - logger.error( - "Sizer failed for step %s: exit code=%d, outputs present=%s", - step.name, - result.returncode, - _has_required_outputs(step), + if result.returncode != 0 or not _has_staging_outputs(step): + logger.error( + "Sizer failed for step %s: exit code=%d, staging present=%s", + step.name, + result.returncode, + _has_staging_outputs(step), + ) + sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Imcomplete) + return StateEnum.Imcomplete + + sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Success) + + ecc = legalize_layout( + workspace, + step, + sizer_staging_def(step), + sizer_staging_verilog(step), ) - sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Imcomplete) - return StateEnum.Imcomplete + try: + if ecc is None: + sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Imcomplete) + return StateEnum.Imcomplete + + sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Success) + saved = ecc_runner.save_data( + workspace=workspace, + step=step, + ecc_module=ecc, + feature_step=False, + ) + if not saved: + _delete_published_outputs(step) + sub_flow.update_step(step_name=save_data_step, state=StateEnum.Imcomplete) + return StateEnum.Imcomplete + + sub_flow.update_step(step_name=save_data_step, state=StateEnum.Success) + return StateEnum.Success + finally: + if ecc is not None: + ecc.close() diff --git a/chipcompiler/tools/ecc_sizer/subflow.py b/chipcompiler/tools/ecc_sizer/subflow.py index dfee297ba..59919f994 100644 --- a/chipcompiler/tools/ecc_sizer/subflow.py +++ b/chipcompiler/tools/ecc_sizer/subflow.py @@ -7,6 +7,8 @@ class SizerSubFlowEnum(Enum): run_sizer = "run sizer" + run_legalization = "run legalization" + save_data = "save data" class SizerSubFlow: @@ -23,23 +25,27 @@ def init_sub_flow(self) -> None: data = json_read(self.workspace_step.subflow.path or "") if len(data) > 0: self.workspace_step.subflow.steps = data.get("steps", []) - else: - self.build_sub_flow() + self.build_sub_flow() - def build_sub_flow(self) -> list[dict]: - if len(self.workspace_step.subflow.steps or []) > 0: - return self.workspace_step.subflow.steps - - steps = [ + def _canonical_steps(self) -> list[dict]: + return [ { - "name": SizerSubFlowEnum.run_sizer.value, + "name": stage.value, "state": StateEnum.Unstart.value, "runtime": "", "peak memory (mb)": 0, "info": {}, } + for stage in SizerSubFlowEnum ] + def build_sub_flow(self) -> list[dict]: + expected = [stage.value for stage in SizerSubFlowEnum] + current = [step_dict.get("name") for step_dict in self.workspace_step.subflow.steps or []] + if current == expected: + return self.workspace_step.subflow.steps + + steps = self._canonical_steps() self.workspace_step.subflow.steps = steps self.save() return steps diff --git a/docs/architecture.md b/docs/architecture.md index df053cc65..e930b196f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ Routing → input/design.def → ... | `WorkspaceStep` | Per-step workspace: inputs, outputs, configs, logs, reports | | `Parameters` | Design specs: die size, clock frequency, buffer/filler/tie cells | | `PDK` | Tech library paths: LEF, liberty, timing, SPEF | -| `StepEnum` | Flow steps: SYNTHESIS, PLACEMENT, CTS, TIMING_OPT (sizer), LEGALIZATION, ROUTING, FILLER | +| `StepEnum` | Flow steps: SYNTHESIS, PLACEMENT, CTS, LEGALIZATION, TIMING_OPT (optional sizer + inner legalize), ROUTING, FILLER | | `StateEnum` | Step states: Unstart, Ongoing, Success, Incomplete, Invalid, Ignored, Pending | ### Engine Layer (chipcompiler/engine/) @@ -162,6 +162,8 @@ Script `scripts/autopatch-ecc-py.sh` collects `.so` dependencies, copies to `bin `build_rtl2gds_flow()` returns complete flow: SYNTHESIS → FLOORPLAN → PLACEMENT → CTS → LEGALIZATION → ROUTING → DRC → FILLER. +Timing Opt (`TIMING_OPT`, tool `sizer`) is not in the default preset. When a workspace inserts it, it belongs **after legalization and before routing**. CTS dirties legality, so the post-CTS `legalization` sibling still runs first. Sizer then sizes cells and Timing Opt legalizes internally before publishing DEF/Verilog. + ### Benchmark Module (benchmark/) Batch testing infrastructure: diff --git a/test/data/test_workspace_layout.py b/test/data/test_workspace_layout.py index 2a6966bdf..573b144f1 100644 --- a/test/data/test_workspace_layout.py +++ b/test/data/test_workspace_layout.py @@ -61,7 +61,7 @@ def test_def_keyword_is_exposed_as_def_attribute(): def test_no_value_coercion_str_stays_str(): - # `db` legitimately holds a str (sizer uses ""); the layout must not coerce it. + # `db` legitimately holds a str; the layout must not coerce it. output = EccOutput(db="/some/str/path") assert output.db == "/some/str/path" assert isinstance(output.db, str) @@ -222,7 +222,7 @@ def test_log_projection_sizer_shape_includes_sizer_script_keys(tmp_path): keys = _shape_keys(step) # sizer is the only shape that populates sizer_env/sizer_cmd. assert keys["script"] == sorted(["dir", "main", "sizer_env", "sizer_cmd"]) - assert step.output.db == "" + assert isinstance(step.output.db, Path) class _CapturingLogger(Logger): diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index 44929508f..e79ab6c79 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -49,21 +49,27 @@ def test_sizer_step_config_writes_env_and_cmd_files(tmp_path, monkeypatch): assert "-prft_only" not in cmd_text assert "-outputPath ." in cmd_text expected_def_out = os.path.relpath( - str(step.output.def_), + sizer_builder.sizer_staging_def(step), step.data.steps[StepEnum.TIMING_OPT.value], ) expected_verilog_out = os.path.relpath( - str(step.output.verilog), + sizer_builder.sizer_staging_verilog(step), step.data.steps[StepEnum.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 + assert expected_def_out == "sizer.def.gz" + assert expected_verilog_out == "sizer.v.gz" assert "-min_route_layer M2" in cmd_text assert "-max_route_layer M7" in cmd_text with open(str(step.subflow.path), encoding="utf-8") as file: subflow = json.load(file) - assert [item["name"] for item in subflow["steps"]] == ["run sizer"] + assert [item["name"] for item in subflow["steps"]] == [ + "run sizer", + "run legalization", + "save data", + ] with open(str(step.checklist.path), encoding="utf-8") as file: checklist = json.load(file) @@ -105,11 +111,11 @@ def test_sizer_config_preserves_runtime_parseable_order(tmp_path, monkeypatch): assert f"-tclFile {runtime_root / 'src' / 'sizer_os.tcl'}" in env_lines expected_def_out = os.path.relpath( - str(step.output.def_), + sizer_builder.sizer_staging_def(step), step.data.steps[StepEnum.TIMING_OPT.value], ) expected_verilog_out = os.path.relpath( - str(step.output.verilog), + sizer_builder.sizer_staging_verilog(step), step.data.steps[StepEnum.TIMING_OPT.value], ) assert cmd_lines == [ @@ -203,7 +209,7 @@ def test_sizer_config_omits_empty_optional_paths(tmp_path, monkeypatch): assert "-spef " not in cmd_lines -def test_sizer_step_declares_no_db_output_and_keeps_standard_dirs(tmp_path): +def test_sizer_step_declares_db_geometry_and_keeps_standard_dirs(tmp_path): from chipcompiler.tools.ecc_sizer import builder as sizer_builder workspace = _workspace(tmp_path) @@ -214,7 +220,9 @@ def test_sizer_step_declares_no_db_output_and_keeps_standard_dirs(tmp_path): input_verilog="input.v", ) - assert step.output.db == "" + 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.directory.name == "timing_optimization_sizer" assert not str(step.directory).endswith(f"{StepEnum.TIMING_OPT.value}_sizer") @@ -377,7 +385,7 @@ def test_sizer_step_info_surfaces_include_step_local_config(tmp_path, monkeypatc "def": str(output.def_), "verilog": str(output.verilog), "gds": str(output.gds), - "db": output.db, + "db": str(output.db), "image": str(output.image), "json": str(output.json), "view_json": str(output.view_json), @@ -393,3 +401,46 @@ def test_sizer_step_info_surfaces_include_step_local_config(tmp_path, monkeypatc "sizer_cmd": str(step.script.sizer_cmd), } assert get_step_info(workspace, step, "unknown") == {} + + +def test_sizer_build_step_config_rewrites_legacy_one_stage_subflow(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(_sizer_runtime(tmp_path))) + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def="input.def", + input_verilog="input.v", + ) + sizer_builder.build_step_space(step) + assert step.subflow.path is not None + step.subflow.path.write_text( + json.dumps( + { + "path": str(step.subflow.path), + "steps": [ + { + "name": "run sizer", + "state": "Success", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ], + } + ), + encoding="utf-8", + ) + + sizer_builder.build_step_config(workspace, step) + + with open(str(step.subflow.path), encoding="utf-8") as file: + subflow = json.load(file) + assert [item["name"] for item in subflow["steps"]] == [ + "run sizer", + "run legalization", + "save data", + ] + assert {item["state"] for item in subflow["steps"]} == {"Unstart"} diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 1823b4782..253862327 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -8,14 +8,64 @@ from ._sizer_helpers import _sizer_runtime, _subflow_states, _workspace +class ExplodingEccModule: + def __getattribute__(self, name): + raise AssertionError(f"Sizer runner used ecc_module.{name}") + + +class FakeLegalizeModule: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +def _write_staging(step: EccStep) -> None: + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + + staging_def = sizer_builder.sizer_staging_def(step) + staging_verilog = sizer_builder.sizer_staging_verilog(step) + staging_def.parent.mkdir(parents=True, exist_ok=True) + staging_def.write_text("def\n", encoding="utf-8") + staging_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + + +def _fake_sizer_run(step: EccStep): + def fake_run(command, cwd, stdout, stderr, check): + del command, cwd, stdout, stderr, check + _write_staging(step) + return SimpleNamespace(returncode=0) + + return fake_run + + +def _patch_success_legalize(monkeypatch, sizer_runner, ecc=None): + legalize_module = ecc or FakeLegalizeModule() + + def fake_legalize(workspace, owner_step, input_def, input_verilog): + del workspace, owner_step, input_def, input_verilog + return legalize_module + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + os.makedirs(os.path.dirname(str(step.output.def_)), exist_ok=True) + Path(step.output.def_).write_text("legal def\n", encoding="utf-8") + Path(step.output.verilog).write_text("module gcd; endmodule\n", encoding="utf-8") + if step.output.geometry_manifest is not None: + Path(step.output.geometry).mkdir(parents=True, exist_ok=True) + Path(step.output.geometry_manifest).write_text("schema=ecc.geometry.v1\n") + return True + + monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + return legalize_module + + def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, monkeypatch): from chipcompiler.tools.ecc_sizer import builder as sizer_builder from chipcompiler.tools.ecc_sizer import runner as sizer_runner - class ExplodingEccModule: - def __getattribute__(self, name): - raise AssertionError(f"Sizer runner used ecc_module.{name}") - workspace = _workspace(tmp_path) step = sizer_builder.build_step( workspace=workspace, @@ -30,16 +80,14 @@ def __getattribute__(self, name): def fake_run(command, cwd, stdout, stderr, check): calls.append((command, cwd, stderr, check)) - os.makedirs(os.path.dirname(str(step.output.def_)), exist_ok=True) - with open(str(step.output.def_), "w", encoding="utf-8") as file: - file.write("def\n") - with open(str(step.output.verilog), "w", encoding="utf-8") as file: - file.write("module gcd; endmodule\n") + _write_staging(step) return SimpleNamespace(returncode=0) + legalize_module = _patch_success_legalize(monkeypatch, sizer_runner) monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) monkeypatch.setattr(subprocess, "run", fake_run) assert ( @@ -50,7 +98,13 @@ def fake_run(command, cwd, stdout, stderr, check): ) == StateEnum.Success ) - assert _subflow_states(step)["run sizer"] == StateEnum.Success.value + states = _subflow_states(step) + assert states["run sizer"] == StateEnum.Success.value + assert states["run legalization"] == StateEnum.Success.value + assert states["save data"] == StateEnum.Success.value + assert Path(step.output.def_).read_text(encoding="utf-8") == "legal def\n" + assert not Path(step.output.def_).read_text(encoding="utf-8").startswith("def\n") + assert legalize_module.closed is True assert calls == [ ( [ @@ -88,6 +142,7 @@ def test_sizer_runner_marks_subflow_invalid_when_tool_or_config_missing(tmp_path assert _subflow_states(step)["run sizer"] == StateEnum.Invalid.value monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) assert step.script.sizer_cmd is not None os.remove(step.script.sizer_cmd) @@ -95,6 +150,36 @@ def test_sizer_runner_marks_subflow_invalid_when_tool_or_config_missing(tmp_path assert _subflow_states(step)["run sizer"] == StateEnum.Invalid.value +def test_sizer_runner_does_not_run_sizer_when_dreamplace_is_missing(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + ran = [] + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: False) + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: ran.append((args, kwargs)) or SimpleNamespace(returncode=0), + ) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Invalid + assert ran == [] + assert _subflow_states(step)["run legalization"] == StateEnum.Invalid.value + assert not Path(step.output.def_).exists() + + def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( tmp_path, monkeypatch, @@ -115,6 +200,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) monkeypatch.setattr( subprocess, "run", @@ -125,6 +211,77 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( assert _subflow_states(step)["run sizer"] == StateEnum.Imcomplete.value +def test_sizer_success_legalize_failure_leaves_published_outputs_empty(tmp_path, monkeypatch): + from chipcompiler.engine.flow import EngineFlow + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("stale\n", encoding="utf-8") + Path(step.output.verilog).write_text("stale\n", encoding="utf-8") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: None) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + states = _subflow_states(step) + assert states["run sizer"] == StateEnum.Success.value + assert states["run legalization"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert not Path(step.output.verilog).exists() + assert sizer_builder.sizer_staging_def(step).is_file() + assert EngineFlow(Workspace()).check_step_result(step) is False + + +def test_sizer_save_data_failure_deletes_partial_outputs(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + return False + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert legalize_module.closed is True + + def test_public_sizer_run_marks_invalid_when_tool_missing(tmp_path, monkeypatch): from chipcompiler.tools import run_step as public_run_step from chipcompiler.tools.ecc_sizer import builder as sizer_builder @@ -195,6 +352,32 @@ def test_timing_opt_step_result_does_not_require_gds(tmp_path): assert EngineFlow(Workspace()).check_step_result(step) is True +def test_timing_opt_step_result_requires_declared_geometry_manifest(tmp_path): + from chipcompiler.engine.flow import EngineFlow + + output_def = tmp_path / "out.def" + output_verilog = tmp_path / "out.v" + geometry = tmp_path / "geometry" + output_def.write_text("def\n", encoding="utf-8") + output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + + step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=output_def, + verilog=output_verilog, + geometry=geometry, + geometry_manifest=geometry / "geometry.manifest", + ), + ) + + assert EngineFlow(Workspace()).check_step_result(step) is False + geometry.mkdir() + (geometry / "geometry.manifest").write_text("schema=ecc.geometry.v1\n") + assert EngineFlow(Workspace()).check_step_result(step) is True + + def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monkeypatch): import chipcompiler.tools as tools_api from chipcompiler.engine import flow as flow_module @@ -202,6 +385,8 @@ def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monk workspace = _workspace(tmp_path) workspace.flow.path = tmp_path / "flow.json" + # Preferred order is legalization then Timing Opt; a trailing extra + # legalize sibling after Timing Opt is still a valid cached-DB boundary. workspace.flow.data = { "steps": [ { @@ -289,3 +474,51 @@ def fake_tool_run(workspace, step, ecc_module): assert init_seen == ["pre-sizer-db", None] assert pre_sizer_db_closed == [True] assert run_seen == [("sizer", "pre-sizer-db"), ("ecc", "post-sizer-db")] + + +def test_engine_flow_clears_cached_db_after_incomplete_sizer_step(tmp_path, monkeypatch): + import chipcompiler.tools as tools_api + from chipcompiler.engine import flow as flow_module + from chipcompiler.engine.flow import EngineFlow + + workspace = _workspace(tmp_path) + workspace.flow.path = tmp_path / "flow.json" + workspace.flow.data = { + "steps": [ + { + "name": StepEnum.TIMING_OPT.value, + "tool": "sizer", + "state": StateEnum.Unstart.value, + } + ] + } + sizer_step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=tmp_path / "sizer.def", + verilog=tmp_path / "sizer.v", + ), + ) + closed = [] + + class CloseableDb: + engine = "pre-sizer-db" + + def has_init(self): + return True + + def close(self): + closed.append(True) + + engine_flow = EngineFlow(workspace) + engine_flow.workspace_steps = [sizer_step] + monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) + monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) + monkeypatch.setattr(tools_api, "run_step", lambda **kwargs: StateEnum.Imcomplete) + monkeypatch.setattr(tools_api, "save_layout_image", lambda workspace, step: True) + monkeypatch.setattr(flow_module, "log_flow", lambda workspace: None) + + assert engine_flow.run_steps() is False + assert closed == [True] + assert engine_flow.engine_db is None From 400cd73e9bafb1f9282ab657a02e006815c79136 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 20:48:54 +0800 Subject: [PATCH 03/90] fix(tools): fail closed when ECC design load is invalid Honor read_def failure, close abandoned engines, and load explicit sources once. Fill the workspace DreamPlace config path before inner legalization if the map is empty. --- chipcompiler/tools/ecc/runner.py | 105 ++++++++++++-------- chipcompiler/tools/ecc_dreamplace/runner.py | 5 + test/tools/ecc/test_runner.py | 70 +++++++++++++ test/tools/ecc_dreamplace/test_module.py | 43 ++++++++ 4 files changed, 180 insertions(+), 43 deletions(-) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index d69f3a276..94b8eb403 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -195,17 +195,26 @@ def create_db_engine( def_source = source_def if source_def is not None else step.input.def_ verilog_source = source_verilog if source_verilog is not None else step.input.verilog - def load_data(): - ecc_module = ECCToolsModule() + def _close_engine(ecc_module: ECCToolsModule | None) -> None: + if ecc_module is None: + return + close = getattr(ecc_module, "close", None) + if callable(close): + close() - ecc_module.init_config( - db_config=workspace.config.get("db"), - output_dir=step.data.dir, - feature_dir=step.feature.dir, - ) + def load_data() -> ECCToolsModule | None: + ecc_module = ECCToolsModule() + keep = False + try: + ecc_module.init_config( + db_config=workspace.config.get("db"), + output_dir=step.data.dir, + feature_dir=step.feature.dir, + ) - db_path = step.input.db or "" - if ecc_module.is_db_data_exists(db_path): + db_path = step.input.db or "" + if not ecc_module.is_db_data_exists(db_path): + return None try: loaded = ecc_module.load_data(path=db_path) except Exception as e: @@ -221,43 +230,49 @@ def load_data(): return None workspace.logger.info(f"Successfully loaded data from {db_path}") + keep = True return ecc_module - else: - return None + finally: + if not keep: + _close_engine(ecc_module) - def load_design(): + def load_design() -> ECCToolsModule | None: ecc_module = ECCToolsModule() + keep = False + try: + ecc_module.init_config( + db_config=workspace.config.get("db"), + output_dir=step.data.dir, + feature_dir=step.feature.dir, + ) - ecc_module.init_config( - db_config=workspace.config.get("db"), - output_dir=step.data.dir, - feature_dir=step.feature.dir, - ) - - ecc_module.init_techlef(workspace.pdk.tech) - ecc_module.init_lefs(workspace.pdk.lefs) - - # if db def exist, read db def - def_path = _existing_input_path(def_source) - verilog_path = _existing_input_path(verilog_source) - - if step.name == StepEnum.LVS.value: - if def_path is None: - return None - if not ecc_module.read_def(def_path): + ecc_module.init_techlef(workspace.pdk.tech) + ecc_module.init_lefs(workspace.pdk.lefs) + + def_path = _existing_input_path(def_source) + verilog_path = _existing_input_path(verilog_source) + + if step.name == StepEnum.LVS.value: + if def_path is None or not ecc_module.read_def(def_path): + return None + elif def_path is not None: + if not ecc_module.read_def(def_path): + return None + elif verilog_path: + ecc_module.read_verilog( + verilog=verilog_path, + top_module=workspace.design.top_module, + ) + else: return None - elif def_path is not None: - ecc_module.read_def(def_path) - elif verilog_path: - # else, read step output verilog - ecc_module.read_verilog(verilog=verilog_path, top_module=workspace.design.top_module) - else: - return None - return ecc_module + keep = True + return ecc_module + finally: + if not keep: + _close_engine(ecc_module) - def is_enable_setup(): - # skip synthesis step + def is_enable_setup() -> bool: if step.name == StepEnum.SYNTHESIS.value: return False @@ -268,15 +283,19 @@ def is_enable_setup(): if not is_eda_exist() or not is_enable_setup(): return None + skip_db = skip_input_db or step.name == StepEnum.LVS.value + if skip_db: + return load_design() + + ecc_module = None try: - ecc_module = None if skip_db else load_data() - if ecc_module is None: - ecc_module = load_design() + ecc_module = load_data() except Exception as e: workspace.logger.warning("Failed to load ECC data; falling back to design input: %s", e) + ecc_module = None + if ecc_module is None: ecc_module = load_design() - return ecc_module diff --git a/chipcompiler/tools/ecc_dreamplace/runner.py b/chipcompiler/tools/ecc_dreamplace/runner.py index 6842d608e..9d714387b 100644 --- a/chipcompiler/tools/ecc_dreamplace/runner.py +++ b/chipcompiler/tools/ecc_dreamplace/runner.py @@ -152,6 +152,11 @@ def legalize_layout( return None if not workspace.config.get("dreamplace"): + from chipcompiler.data import build_workspace_config_paths + + workspace.config["dreamplace"] = build_workspace_config_paths(workspace)["dreamplace"] + dreamplace_config = workspace.config.get("dreamplace") + if not dreamplace_config or not Path(dreamplace_config).is_file(): logger.error( "DreamPlace config is missing for inner legalization of %s", owner_step.name, diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 7f148b6b6..1e22f573f 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -274,6 +274,76 @@ def test_create_db_engine_skip_input_db_reads_explicit_sources(tmp_path, monkeyp assert not any(call[0] == "read_def" and call[1] == str(step_def) for call in module.calls) +def test_create_db_engine_returns_none_and_closes_when_read_def_fails(tmp_path, monkeypatch): + design_def = tmp_path / "origin" / "gcd.def" + design_def.parent.mkdir() + design_def.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + pdk=PDK(tech=tmp_path / "tech.lef", lefs=[tmp_path / "std.lef"]), + config={"db": tmp_path / "config" / "db_ecc.json"}, + ) + step = EccStep( + name=StepEnum.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"), + ) + FakeEccModule.instances = [] + monkeypatch.setattr(ecc_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(ecc_runner, "ECCToolsModule", FakeEccModule) + + def failing_read_def(self, path): + self.calls.append(("read_def", path)) + return False + + monkeypatch.setattr(FakeEccModule, "read_def", failing_read_def) + + module = ecc_runner.create_db_engine(workspace, step, skip_input_db=True) + + assert module is None + constructed = FakeEccModule.instances[-1] + assert ("read_def", str(design_def)) in constructed.calls + assert constructed.calls[-1] == ("close",) + + +def test_create_db_engine_skip_input_db_does_not_retry_load_design(tmp_path, monkeypatch): + design_def = tmp_path / "origin" / "gcd.def" + design_def.parent.mkdir() + design_def.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") + + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + pdk=PDK(tech=tmp_path / "tech.lef", lefs=[tmp_path / "std.lef"]), + config={"db": tmp_path / "config" / "db_ecc.json"}, + logger=FakeLogger(), + ) + step = EccStep( + name=StepEnum.TIMING_OPT.value, + input=StepInput(def_=design_def, db=tmp_path / "input_db"), + data=EccData(dir=tmp_path / "timing_optimization_sizer" / "data"), + feature=EccFeature(dir=tmp_path / "timing_optimization_sizer" / "feature"), + ) + constructions = [] + monkeypatch.setattr(ecc_runner, "is_eda_exist", lambda: True) + + class ExplodingModule: + def __init__(self): + constructions.append(1) + raise RuntimeError("native init failed") + + monkeypatch.setattr(ecc_runner, "ECCToolsModule", ExplodingModule) + + with pytest.raises(RuntimeError, match="native init failed"): + ecc_runner.create_db_engine(workspace, step, skip_input_db=True) + + assert constructions == [1] + assert workspace.logger.warnings == [] + + def test_run_cts_merges_structured_timing_into_step_feature(tmp_path, monkeypatch): workspace = Workspace( directory=tmp_path, diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index 9117a4b6c..1fea19e62 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -237,6 +237,49 @@ def test_legalize_layout_returns_none_without_dreamplace_config(tmp_path, monkey ) +def test_legalize_layout_fills_missing_dreamplace_config_without_clobbering(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner + from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule + + workspace_dir = tmp_path / "workspace" + config_path = workspace_dir / "config" / "dreamplace_ecc.json" + config_path.parent.mkdir(parents=True) + json_write(config_path, {}) + workspace = Workspace( + directory=str(workspace_dir), + design=OriginDesign(name="gcd"), + config={"db": workspace_dir / "config" / "db_ecc.json"}, + ) + step = EccStep( + name=StepEnum.TIMING_OPT.value, + data=EccData( + dir=tmp_path / "data", + steps={StepEnum.TIMING_OPT.value: tmp_path / "data" / "to"}, + ), + log=LogPaths(file=tmp_path / "step.log"), + ) + engine = SimpleNamespace(close=lambda: None) + monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr( + dreamplace_runner.ecc_runner, + "create_db_engine", + lambda *args, **kwargs: engine, + ) + monkeypatch.setattr(DreamplaceModule, "run_legalization", lambda self: True) + + assert ( + dreamplace_runner.legalize_layout( + workspace, + step, + tmp_path / "sizer.def.gz", + tmp_path / "sizer.v.gz", + ) + is engine + ) + assert workspace.config["db"] == workspace_dir / "config" / "db_ecc.json" + assert workspace.config["dreamplace"] == config_path + + def test_legalize_layout_returns_engine_when_legalize_succeeds(tmp_path, monkeypatch): from chipcompiler.tools.ecc_dreamplace import runner as dreamplace_runner from chipcompiler.tools.ecc_dreamplace.module import DreamplaceModule From a2fc89bb5cb9c1a983302c83c166196e623aa2da Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 20:48:55 +0800 Subject: [PATCH 04/90] fix(tools): drop stale Sizer staging and partial outputs Clear previous staging before launching Sizer, and delete published Timing Opt files on every unsuccessful save, including exceptions. --- chipcompiler/tools/ecc_sizer/runner.py | 68 +++++++--- test/tools/ecc_sizer/test_runner.py | 177 ++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 22 deletions(-) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index 0eab82f48..f606ac349 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -25,26 +25,46 @@ def _published_paths(step: EccStep) -> list[Path]: if not isinstance(output, EccOutput): return [] - paths: list[Path] = [] - for value in ( + candidates = [ output.def_, output.verilog, output.gds, output.db, output.geometry, output.geometry_manifest, - ): - if value: - paths.append(Path(value)) - return paths - - -def _delete_published_outputs(step: EccStep) -> None: - for path in _published_paths(step): + output.image, + output.json, + output.view_json, + output.view_json_edits, + output.lef, + output.lib, + step.feature.db, + step.feature.step, + step.feature.map, + step.report.db, + step.report.step, + ] + return [Path(value) for value in candidates if value] + + +def _delete_path(path: Path) -> None: + try: if path.is_symlink() or path.is_file(): path.unlink() elif path.is_dir(): shutil.rmtree(path) + except OSError: + logger.warning("Failed to delete Timing Opt artifact %s", path, exc_info=True) + + +def _delete_published_outputs(step: EccStep) -> None: + for path in _published_paths(step): + _delete_path(path) + + +def _delete_staging_outputs(step: EccStep) -> None: + _delete_path(sizer_staging_def(step)) + _delete_path(sizer_staging_verilog(step)) def run_step( @@ -88,6 +108,7 @@ def run_step( log_path = step.log.file or "" os.makedirs(os.path.dirname(log_path), exist_ok=True) os.makedirs(os.path.dirname(step.output.def_ or ""), exist_ok=True) + _delete_staging_outputs(step) command = get_sizer_command() + ["-env", str(env_path), "-f", str(cmd_path)] with open(log_path, "w", encoding="utf-8") as log_file: @@ -117,25 +138,34 @@ def run_step( sizer_staging_def(step), sizer_staging_verilog(step), ) + published = False try: if ecc is None: sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Imcomplete) return StateEnum.Imcomplete sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Success) - saved = ecc_runner.save_data( - workspace=workspace, - step=step, - ecc_module=ecc, - feature_step=False, - ) + try: + saved = ecc_runner.save_data( + workspace=workspace, + step=step, + ecc_module=ecc, + feature_step=False, + ) + except Exception: + logger.exception("Failed to publish Timing Opt outputs for %s", step.name) + saved = False if not saved: - _delete_published_outputs(step) sub_flow.update_step(step_name=save_data_step, state=StateEnum.Imcomplete) return StateEnum.Imcomplete + published = True sub_flow.update_step(step_name=save_data_step, state=StateEnum.Success) return StateEnum.Success finally: - if ecc is not None: - ecc.close() + try: + if not published: + _delete_published_outputs(step) + finally: + if ecc is not None: + ecc.close() diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 253862327..9805d0890 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -3,6 +3,8 @@ from pathlib import Path from types import SimpleNamespace +import pytest + from chipcompiler.data import EccOutput, EccStep, StateEnum, StepEnum, Workspace from ._sizer_helpers import _sizer_runtime, _subflow_states, _workspace @@ -40,11 +42,18 @@ def fake_run(command, cwd, stdout, stderr, check): return fake_run -def _patch_success_legalize(monkeypatch, sizer_runner, ecc=None): +def _patch_success_legalize(monkeypatch, sizer_runner, step: EccStep, ecc=None): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + legalize_module = ecc or FakeLegalizeModule() + seen = [] def fake_legalize(workspace, owner_step, input_def, input_verilog): - del workspace, owner_step, input_def, input_verilog + del workspace + seen.append((owner_step, Path(input_def), Path(input_verilog))) + assert owner_step is step + assert Path(input_def) == sizer_builder.sizer_staging_def(step) + assert Path(input_verilog) == sizer_builder.sizer_staging_verilog(step) return legalize_module def fake_save(*, workspace, step, ecc_module, feature_step): @@ -59,6 +68,7 @@ def fake_save(*, workspace, step, ecc_module, feature_step): monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + legalize_module.seen = seen return legalize_module @@ -83,7 +93,7 @@ def fake_run(command, cwd, stdout, stderr, check): _write_staging(step) return SimpleNamespace(returncode=0) - legalize_module = _patch_success_legalize(monkeypatch, sizer_runner) + legalize_module = _patch_success_legalize(monkeypatch, sizer_runner, step) monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) @@ -105,6 +115,7 @@ def fake_run(command, cwd, stdout, stderr, check): assert Path(step.output.def_).read_text(encoding="utf-8") == "legal def\n" assert not Path(step.output.def_).read_text(encoding="utf-8").startswith("def\n") assert legalize_module.closed is True + assert len(legalize_module.seen) == 1 assert calls == [ ( [ @@ -282,6 +293,166 @@ def fake_save(*, workspace, step, ecc_module, feature_step): assert legalize_module.closed is True +def test_sizer_save_data_failure_deletes_feature_report_and_image(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + Path(step.feature.db).write_text("stale feature\n", encoding="utf-8") + Path(step.report.db).write_text("stale report\n", encoding="utf-8") + Path(step.output.image).write_text("stale image\n", encoding="utf-8") + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.feature.db).write_text("partial feature\n", encoding="utf-8") + return False + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert not Path(step.feature.db).exists() + assert not Path(step.report.db).exists() + assert not Path(step.output.image).exists() + assert legalize_module.closed is True + + +def test_sizer_closes_engine_when_published_cleanup_fails(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + return False + + delete_calls = [] + + def exploding_delete(step): + delete_calls.append(step) + if len(delete_calls) > 1: + raise OSError("cannot unlink") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + monkeypatch.setattr(sizer_runner, "_delete_published_outputs", exploding_delete) + + with pytest.raises(OSError, match="cannot unlink"): + sizer_runner.run_step(workspace, step) + assert legalize_module.closed is True + + +def test_sizer_rerun_does_not_legalize_stale_staging_when_sizer_writes_nothing( + tmp_path, + monkeypatch, +): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + _write_staging(step) + sizer_builder.sizer_staging_def(step).write_text("stale def\n", encoding="utf-8") + + legalize_calls = [] + + def fake_legalize(*args, **kwargs): + legalize_calls.append((args, kwargs)) + return FakeLegalizeModule() + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert legalize_calls == [] + assert not sizer_builder.sizer_staging_def(step).exists() + assert not sizer_builder.sizer_staging_verilog(step).exists() + + +def test_sizer_save_data_exception_deletes_partial_outputs(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + raise RuntimeError("geometry snapshot failed") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert legalize_module.closed is True + + def test_public_sizer_run_marks_invalid_when_tool_missing(tmp_path, monkeypatch): from chipcompiler.tools import run_step as public_run_step from chipcompiler.tools.ecc_sizer import builder as sizer_builder From e8d4078b36684124b51af64677901bb12124ded8 Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 21:26:42 +0800 Subject: [PATCH 05/90] fix(tools): fail closed on Sizer cleanup and reset subflow Raise if staging or published artifacts cannot be deleted, and reset Timing Opt subflow stages at the start of every Sizer attempt. --- chipcompiler/tools/ecc_sizer/runner.py | 24 +++++--- chipcompiler/tools/ecc_sizer/subflow.py | 21 +++++-- test/tools/ecc_sizer/test_runner.py | 82 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index f606ac349..c98afc582 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -48,18 +48,22 @@ def _published_paths(step: EccStep) -> list[Path]: def _delete_path(path: Path) -> None: - try: - if path.is_symlink() or path.is_file(): - path.unlink() - elif path.is_dir(): - shutil.rmtree(path) - except OSError: - logger.warning("Failed to delete Timing Opt artifact %s", path, exc_info=True) + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) def _delete_published_outputs(step: EccStep) -> None: + errors: list[OSError] = [] for path in _published_paths(step): - _delete_path(path) + try: + _delete_path(path) + except OSError as exc: + logger.warning("Failed to delete Timing Opt artifact %s", path, exc_info=True) + errors.append(exc) + if errors: + raise errors[0] def _delete_staging_outputs(step: EccStep) -> None: @@ -79,6 +83,7 @@ def run_step( run_legalization_step = SizerSubFlowEnum.run_legalization.value save_data_step = SizerSubFlowEnum.save_data.value + sub_flow.reset_stages() _delete_published_outputs(step) if not is_eda_exist() or not is_sizer_runtime_exist(): @@ -109,6 +114,7 @@ def run_step( os.makedirs(os.path.dirname(log_path), exist_ok=True) os.makedirs(os.path.dirname(step.output.def_ or ""), exist_ok=True) _delete_staging_outputs(step) + sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Ongoing) command = get_sizer_command() + ["-env", str(env_path), "-f", str(cmd_path)] with open(log_path, "w", encoding="utf-8") as log_file: @@ -131,6 +137,7 @@ def run_step( return StateEnum.Imcomplete sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Success) + sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Ongoing) ecc = legalize_layout( workspace, @@ -145,6 +152,7 @@ def run_step( return StateEnum.Imcomplete sub_flow.update_step(step_name=run_legalization_step, state=StateEnum.Success) + sub_flow.update_step(step_name=save_data_step, state=StateEnum.Ongoing) try: saved = ecc_runner.save_data( workspace=workspace, diff --git a/chipcompiler/tools/ecc_sizer/subflow.py b/chipcompiler/tools/ecc_sizer/subflow.py index 59919f994..6e045e713 100644 --- a/chipcompiler/tools/ecc_sizer/subflow.py +++ b/chipcompiler/tools/ecc_sizer/subflow.py @@ -42,13 +42,24 @@ def _canonical_steps(self) -> list[dict]: def build_sub_flow(self) -> list[dict]: expected = [stage.value for stage in SizerSubFlowEnum] current = [step_dict.get("name") for step_dict in self.workspace_step.subflow.steps or []] - if current == expected: - return self.workspace_step.subflow.steps + if current != expected: + self.workspace_step.subflow.steps = self._canonical_steps() + self.save() + return self.workspace_step.subflow.steps - steps = self._canonical_steps() - self.workspace_step.subflow.steps = steps + def reset_stages(self) -> list[dict]: + expected = [stage.value for stage in SizerSubFlowEnum] + current = [step_dict.get("name") for step_dict in self.workspace_step.subflow.steps or []] + if current != expected: + self.workspace_step.subflow.steps = self._canonical_steps() + else: + for step_dict in self.workspace_step.subflow.steps or []: + step_dict["state"] = StateEnum.Unstart.value + step_dict["runtime"] = "" + step_dict["peak memory (mb)"] = 0 + step_dict["info"] = {} self.save() - return steps + return self.workspace_step.subflow.steps def save(self) -> bool: from chipcompiler.utility import json_write diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 9805d0890..6bf826183 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -375,6 +375,88 @@ def exploding_delete(step): assert legalize_module.closed is True +def test_sizer_does_not_legalize_when_staging_cleanup_fails(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + _write_staging(step) + + legalize_calls = [] + original_delete = sizer_runner._delete_path + + def fail_staging_delete(path): + if Path(path).name.startswith("sizer."): + raise OSError("cannot unlink staging") + original_delete(path) + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "_delete_path", fail_staging_delete) + + def record_legalize(*args, **kwargs): + del args, kwargs + legalize_calls.append(1) + + monkeypatch.setattr(sizer_runner, "legalize_layout", record_legalize) + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + + with pytest.raises(OSError, match="cannot unlink staging"): + sizer_runner.run_step(workspace, step) + assert legalize_calls == [] + + +def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + _patch_success_legalize(monkeypatch, sizer_runner, step) + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Success + assert _subflow_states(step)["run legalization"] == StateEnum.Success.value + assert _subflow_states(step)["save data"] == StateEnum.Success.value + + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + states = _subflow_states(step) + assert states["run sizer"] == StateEnum.Imcomplete.value + assert states["run legalization"] == StateEnum.Unstart.value + assert states["save data"] == StateEnum.Unstart.value + + def test_sizer_rerun_does_not_legalize_stale_staging_when_sizer_writes_nothing( tmp_path, monkeypatch, From 2b68a6b2005ed30990224465c2c05d98228af39a Mon Sep 17 00:00:00 2001 From: Emin Date: Mon, 31 Aug 2026 23:54:13 +0800 Subject: [PATCH 06/90] fix(tools): map Timing Opt configs and split sizer tests Register Timing Opt/sizer workspace configs so `ecc config` can inspect the db and DreamPlace files the inner legalize path actually reads. Split the oversized sizer runner tests into runner, cleanup, and EngineFlow modules. --- chipcompiler/data/workspace/__init__.py | 1 + test/cli/inspect/test_config.py | 43 ++ test/data/test_workspace.py | 6 + test/tools/ecc_sizer/_sizer_helpers.py | 77 ++- test/tools/ecc_sizer/test_engine_flow.py | 199 +++++++ test/tools/ecc_sizer/test_runner.py | 581 +------------------- test/tools/ecc_sizer/test_runner_cleanup.py | 329 +++++++++++ 7 files changed, 658 insertions(+), 578 deletions(-) create mode 100644 test/tools/ecc_sizer/test_engine_flow.py create mode 100644 test/tools/ecc_sizer/test_runner_cleanup.py diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py index 39d39021b..1b5b97497 100644 --- a/chipcompiler/data/workspace/__init__.py +++ b/chipcompiler/data/workspace/__init__.py @@ -182,6 +182,7 @@ 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"), } diff --git a/test/cli/inspect/test_config.py b/test/cli/inspect/test_config.py index 40c531094..412ea0b66 100644 --- a/test/cli/inspect/test_config.py +++ b/test/cli/inspect/test_config.py @@ -297,6 +297,49 @@ def test_config_dreamplace_legalization_uses_dreamplace_config( ] assert data["records"][0]["source"] == "workspace_config" + def test_config_sizer_timing_opt_uses_db_and_dreamplace_configs( + self, + tmp_path, + capsys, + create_cli_project, + create_flow_json, + create_step_dir, + create_workspace_config, + ): + project_dir = create_cli_project() + run_dir = os.path.join(project_dir, "runs", "default") + create_flow_json( + run_dir, + [ + { + "name": "Timing optimization", + "tool": "sizer", + "state": "Success", + "runtime": "0:00:04", + }, + ], + ) + create_step_dir(run_dir, "Timing optimization", "sizer", subdirs=["output"]) + create_workspace_config( + run_dir, + { + "db_ecc.json": "{}", + "dreamplace_ecc.json": "{}", + }, + ) + + rc = cli_main.run( + ["config", "timing optimization", "--resolved", "--json", "--project", project_dir] + ) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert [item["path"] for item in data["records"]] == [ + "runs/default/config/db_ecc.json", + "runs/default/config/dreamplace_ecc.json", + ] + assert all(item["source"] == "workspace_config" for item in data["records"]) + assert all(item["step"] == "timing optimization" for item in data["records"]) + def test_config_cli_tokens_use_internal_flow_step_names( self, capsys, diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index 78fa351c8..d6dff0e10 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -445,6 +445,8 @@ 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("synthesis", "yosys") == () assert data_api.step_config_keys("place", None) == () @@ -494,6 +496,10 @@ 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") == ( + config_dir / "db_ecc.json", + config_dir / "dreamplace_ecc.json", + ) assert data_api.step_config_paths(workspace_dir, "place", "ECC") == () assert data_api.step_config_paths(workspace_dir, "synthesis", "yosys") == () diff --git a/test/tools/ecc_sizer/_sizer_helpers.py b/test/tools/ecc_sizer/_sizer_helpers.py index 4349a8ba2..522a44f15 100644 --- a/test/tools/ecc_sizer/_sizer_helpers.py +++ b/test/tools/ecc_sizer/_sizer_helpers.py @@ -1,16 +1,19 @@ """Shared helpers for the ecc_sizer tool tests. -Kept local to this tool's test directory and imported by both the -builder/config tests (`test_module.py`) and the runner/flow tests -(`test_runner.py`): `_workspace` builds a sizer-ready workspace, -`_subflow_states` reads a step's persisted subflow state map, and -`_sizer_runtime` lays down a fake sizer runtime tree. +Kept local to this tool's test directory and imported by the +builder/config tests (`test_module.py`) and the runner/flow tests: +`_workspace` builds a sizer-ready workspace, `_subflow_states` reads a +step's persisted subflow state map, and `_sizer_runtime` lays down a +fake sizer runtime tree. Runner tests also share `_write_staging`, +`_fake_sizer_run`, and `_patch_success_legalize`. """ import json +import os from pathlib import Path +from types import SimpleNamespace -from chipcompiler.data import PDK, OriginDesign, Parameters, Workspace +from chipcompiler.data import PDK, EccStep, OriginDesign, Parameters, Workspace def _workspace(tmp_path): @@ -43,3 +46,65 @@ def _sizer_runtime(tmp_path): (root / "src" / "sizer_os.tcl").write_text("# sizer tcl\n", encoding="utf-8") (root / "submit" / "env_base_file").write_text("-num_vt 1\n", encoding="utf-8") return root + + +class ExplodingEccModule: + def __getattribute__(self, name): + raise AssertionError(f"Sizer runner used ecc_module.{name}") + + +class FakeLegalizeModule: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + +def _write_staging(step: EccStep) -> None: + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + + staging_def = sizer_builder.sizer_staging_def(step) + staging_verilog = sizer_builder.sizer_staging_verilog(step) + staging_def.parent.mkdir(parents=True, exist_ok=True) + staging_def.write_text("def\n", encoding="utf-8") + staging_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + + +def _fake_sizer_run(step: EccStep): + def fake_run(command, cwd, stdout, stderr, check): + del command, cwd, stdout, stderr, check + _write_staging(step) + return SimpleNamespace(returncode=0) + + return fake_run + + +def _patch_success_legalize(monkeypatch, sizer_runner, step: EccStep, ecc=None): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + + legalize_module = ecc or FakeLegalizeModule() + seen = [] + + def fake_legalize(workspace, owner_step, input_def, input_verilog): + del workspace + seen.append((owner_step, Path(input_def), Path(input_verilog))) + assert owner_step is step + assert Path(input_def) == sizer_builder.sizer_staging_def(step) + assert Path(input_verilog) == sizer_builder.sizer_staging_verilog(step) + return legalize_module + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + os.makedirs(os.path.dirname(str(step.output.def_)), exist_ok=True) + Path(step.output.def_).write_text("legal def\n", encoding="utf-8") + Path(step.output.verilog).write_text("module gcd; endmodule\n", encoding="utf-8") + if step.output.geometry_manifest is not None: + Path(step.output.geometry).mkdir(parents=True, exist_ok=True) + Path(step.output.geometry_manifest).write_text("schema=ecc.geometry.v1\n") + return True + + monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + legalize_module.seen = seen + return legalize_module diff --git a/test/tools/ecc_sizer/test_engine_flow.py b/test/tools/ecc_sizer/test_engine_flow.py new file mode 100644 index 000000000..63e855f99 --- /dev/null +++ b/test/tools/ecc_sizer/test_engine_flow.py @@ -0,0 +1,199 @@ +import os +from types import SimpleNamespace + +from chipcompiler.data import EccOutput, EccStep, StateEnum, StepEnum, Workspace + +from ._sizer_helpers import _workspace + + +def test_timing_opt_step_result_does_not_require_gds(tmp_path): + from chipcompiler.engine.flow import EngineFlow + + output_def = tmp_path / "out.def" + output_verilog = tmp_path / "out.v" + output_def.write_text("def\n", encoding="utf-8") + output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + + step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=output_def, + verilog=output_verilog, + gds=tmp_path / "missing.gds", + ), + ) + + assert EngineFlow(Workspace()).check_step_result(step) is True + + +def test_timing_opt_step_result_requires_declared_geometry_manifest(tmp_path): + from chipcompiler.engine.flow import EngineFlow + + output_def = tmp_path / "out.def" + output_verilog = tmp_path / "out.v" + geometry = tmp_path / "geometry" + output_def.write_text("def\n", encoding="utf-8") + output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + + step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=output_def, + verilog=output_verilog, + geometry=geometry, + geometry_manifest=geometry / "geometry.manifest", + ), + ) + + assert EngineFlow(Workspace()).check_step_result(step) is False + geometry.mkdir() + (geometry / "geometry.manifest").write_text("schema=ecc.geometry.v1\n") + assert EngineFlow(Workspace()).check_step_result(step) is True + + +def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monkeypatch): + import chipcompiler.tools as tools_api + from chipcompiler.engine import flow as flow_module + from chipcompiler.engine.flow import EngineFlow + + workspace = _workspace(tmp_path) + workspace.flow.path = tmp_path / "flow.json" + # Preferred order is legalization then Timing Opt; a trailing extra + # legalize sibling after Timing Opt is still a valid cached-DB boundary. + workspace.flow.data = { + "steps": [ + { + "name": StepEnum.TIMING_OPT.value, + "tool": "sizer", + "state": StateEnum.Unstart.value, + }, + { + "name": StepEnum.LEGALIZATION.value, + "tool": "ecc", + "state": StateEnum.Unstart.value, + }, + ] + } + + sizer_step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=tmp_path / "sizer.def", + verilog=tmp_path / "sizer.v", + ), + ) + post_sizer_step = EccStep( + name=StepEnum.LEGALIZATION.value, + tool="ecc", + output=EccOutput( + def_=tmp_path / "post.def", + verilog=tmp_path / "post.v", + gds=tmp_path / "post.gds", + ), + ) + pre_sizer_db_closed = [] + + class CloseableDb: + engine = "pre-sizer-db" + + def has_init(self): + return True + + def close(self): + pre_sizer_db_closed.append(True) + + engine_flow = EngineFlow(workspace) + engine_flow.workspace_steps = [sizer_step, post_sizer_step] + monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) + + init_seen = [] + run_seen = [] + + def fake_init_db_engine(): + current_db = engine_flow.engine_db + init_seen.append(None if current_db is None else current_db.engine) + if current_db is None: + assert pre_sizer_db_closed == [True] + monkeypatch.setattr( + engine_flow, + "engine_db", + SimpleNamespace(engine="post-sizer-db", has_init=lambda: True), + ) + return True + + def fake_tool_run(workspace, step, ecc_module): + del workspace + run_seen.append( + ( + step.tool, + ecc_module, + ) + ) + for path in (step.output.def_, step.output.verilog, step.output.gds): + if path is None: + continue + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as file: + file.write("\n") + return StateEnum.Success + + monkeypatch.setattr(engine_flow, "init_db_engine", fake_init_db_engine) + monkeypatch.setattr(tools_api, "run_step", fake_tool_run) + monkeypatch.setattr(tools_api, "save_layout_image", lambda workspace, step: True) + monkeypatch.setattr(flow_module, "log_flow", lambda workspace: None) + + assert engine_flow.run_steps() is True + assert init_seen == ["pre-sizer-db", None] + assert pre_sizer_db_closed == [True] + assert run_seen == [("sizer", "pre-sizer-db"), ("ecc", "post-sizer-db")] + + +def test_engine_flow_clears_cached_db_after_incomplete_sizer_step(tmp_path, monkeypatch): + import chipcompiler.tools as tools_api + from chipcompiler.engine import flow as flow_module + from chipcompiler.engine.flow import EngineFlow + + workspace = _workspace(tmp_path) + workspace.flow.path = tmp_path / "flow.json" + workspace.flow.data = { + "steps": [ + { + "name": StepEnum.TIMING_OPT.value, + "tool": "sizer", + "state": StateEnum.Unstart.value, + } + ] + } + sizer_step = EccStep( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + output=EccOutput( + def_=tmp_path / "sizer.def", + verilog=tmp_path / "sizer.v", + ), + ) + closed = [] + + class CloseableDb: + engine = "pre-sizer-db" + + def has_init(self): + return True + + def close(self): + closed.append(True) + + engine_flow = EngineFlow(workspace) + engine_flow.workspace_steps = [sizer_step] + monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) + monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) + monkeypatch.setattr(tools_api, "run_step", lambda **kwargs: StateEnum.Imcomplete) + monkeypatch.setattr(tools_api, "save_layout_image", lambda workspace, step: True) + monkeypatch.setattr(flow_module, "log_flow", lambda workspace: None) + + assert engine_flow.run_steps() is False + assert closed == [True] + assert engine_flow.engine_db is None diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 6bf826183..d88c9f879 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -3,73 +3,16 @@ from pathlib import Path from types import SimpleNamespace -import pytest +from chipcompiler.data import StateEnum, StepEnum -from chipcompiler.data import EccOutput, EccStep, StateEnum, StepEnum, Workspace - -from ._sizer_helpers import _sizer_runtime, _subflow_states, _workspace - - -class ExplodingEccModule: - def __getattribute__(self, name): - raise AssertionError(f"Sizer runner used ecc_module.{name}") - - -class FakeLegalizeModule: - def __init__(self): - self.closed = False - - def close(self): - self.closed = True - - -def _write_staging(step: EccStep) -> None: - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - - staging_def = sizer_builder.sizer_staging_def(step) - staging_verilog = sizer_builder.sizer_staging_verilog(step) - staging_def.parent.mkdir(parents=True, exist_ok=True) - staging_def.write_text("def\n", encoding="utf-8") - staging_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") - - -def _fake_sizer_run(step: EccStep): - def fake_run(command, cwd, stdout, stderr, check): - del command, cwd, stdout, stderr, check - _write_staging(step) - return SimpleNamespace(returncode=0) - - return fake_run - - -def _patch_success_legalize(monkeypatch, sizer_runner, step: EccStep, ecc=None): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - - legalize_module = ecc or FakeLegalizeModule() - seen = [] - - def fake_legalize(workspace, owner_step, input_def, input_verilog): - del workspace - seen.append((owner_step, Path(input_def), Path(input_verilog))) - assert owner_step is step - assert Path(input_def) == sizer_builder.sizer_staging_def(step) - assert Path(input_verilog) == sizer_builder.sizer_staging_verilog(step) - return legalize_module - - def fake_save(*, workspace, step, ecc_module, feature_step): - del workspace, ecc_module, feature_step - os.makedirs(os.path.dirname(str(step.output.def_)), exist_ok=True) - Path(step.output.def_).write_text("legal def\n", encoding="utf-8") - Path(step.output.verilog).write_text("module gcd; endmodule\n", encoding="utf-8") - if step.output.geometry_manifest is not None: - Path(step.output.geometry).mkdir(parents=True, exist_ok=True) - Path(step.output.geometry_manifest).write_text("schema=ecc.geometry.v1\n") - return True - - monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) - monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) - legalize_module.seen = seen - return legalize_module +from ._sizer_helpers import ( + ExplodingEccModule, + _patch_success_legalize, + _sizer_runtime, + _subflow_states, + _workspace, + _write_staging, +) def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, monkeypatch): @@ -222,319 +165,6 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( assert _subflow_states(step)["run sizer"] == StateEnum.Imcomplete.value -def test_sizer_success_legalize_failure_leaves_published_outputs_empty(tmp_path, monkeypatch): - from chipcompiler.engine.flow import EngineFlow - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) - Path(step.output.def_).write_text("stale\n", encoding="utf-8") - Path(step.output.verilog).write_text("stale\n", encoding="utf-8") - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: None) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - states = _subflow_states(step) - assert states["run sizer"] == StateEnum.Success.value - assert states["run legalization"] == StateEnum.Imcomplete.value - assert not Path(step.output.def_).exists() - assert not Path(step.output.verilog).exists() - assert sizer_builder.sizer_staging_def(step).is_file() - assert EngineFlow(Workspace()).check_step_result(step) is False - - -def test_sizer_save_data_failure_deletes_partial_outputs(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - - legalize_module = FakeLegalizeModule() - - def fake_save(*, workspace, step, ecc_module, feature_step): - del workspace, ecc_module, feature_step - Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) - Path(step.output.def_).write_text("partial\n", encoding="utf-8") - return False - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) - monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value - assert not Path(step.output.def_).exists() - assert legalize_module.closed is True - - -def test_sizer_save_data_failure_deletes_feature_report_and_image(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - Path(step.feature.db).write_text("stale feature\n", encoding="utf-8") - Path(step.report.db).write_text("stale report\n", encoding="utf-8") - Path(step.output.image).write_text("stale image\n", encoding="utf-8") - - legalize_module = FakeLegalizeModule() - - def fake_save(*, workspace, step, ecc_module, feature_step): - del workspace, ecc_module, feature_step - Path(step.feature.db).write_text("partial feature\n", encoding="utf-8") - return False - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) - monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - assert not Path(step.feature.db).exists() - assert not Path(step.report.db).exists() - assert not Path(step.output.image).exists() - assert legalize_module.closed is True - - -def test_sizer_closes_engine_when_published_cleanup_fails(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - - legalize_module = FakeLegalizeModule() - - def fake_save(*, workspace, step, ecc_module, feature_step): - del workspace, ecc_module, feature_step - Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) - Path(step.output.def_).write_text("partial\n", encoding="utf-8") - return False - - delete_calls = [] - - def exploding_delete(step): - delete_calls.append(step) - if len(delete_calls) > 1: - raise OSError("cannot unlink") - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) - monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) - monkeypatch.setattr(sizer_runner, "_delete_published_outputs", exploding_delete) - - with pytest.raises(OSError, match="cannot unlink"): - sizer_runner.run_step(workspace, step) - assert legalize_module.closed is True - - -def test_sizer_does_not_legalize_when_staging_cleanup_fails(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - _write_staging(step) - - legalize_calls = [] - original_delete = sizer_runner._delete_path - - def fail_staging_delete(path): - if Path(path).name.startswith("sizer."): - raise OSError("cannot unlink staging") - original_delete(path) - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "_delete_path", fail_staging_delete) - - def record_legalize(*args, **kwargs): - del args, kwargs - legalize_calls.append(1) - - monkeypatch.setattr(sizer_runner, "legalize_layout", record_legalize) - monkeypatch.setattr( - subprocess, - "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), - ) - - with pytest.raises(OSError, match="cannot unlink staging"): - sizer_runner.run_step(workspace, step) - assert legalize_calls == [] - - -def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - - _patch_success_legalize(monkeypatch, sizer_runner, step) - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Success - assert _subflow_states(step)["run legalization"] == StateEnum.Success.value - assert _subflow_states(step)["save data"] == StateEnum.Success.value - - monkeypatch.setattr( - subprocess, - "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), - ) - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - states = _subflow_states(step) - assert states["run sizer"] == StateEnum.Imcomplete.value - assert states["run legalization"] == StateEnum.Unstart.value - assert states["save data"] == StateEnum.Unstart.value - - -def test_sizer_rerun_does_not_legalize_stale_staging_when_sizer_writes_nothing( - tmp_path, - monkeypatch, -): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - _write_staging(step) - sizer_builder.sizer_staging_def(step).write_text("stale def\n", encoding="utf-8") - - legalize_calls = [] - - def fake_legalize(*args, **kwargs): - legalize_calls.append((args, kwargs)) - return FakeLegalizeModule() - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr( - subprocess, - "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), - ) - monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - assert legalize_calls == [] - assert not sizer_builder.sizer_staging_def(step).exists() - assert not sizer_builder.sizer_staging_verilog(step).exists() - - -def test_sizer_save_data_exception_deletes_partial_outputs(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer import builder as sizer_builder - from chipcompiler.tools.ecc_sizer import runner as sizer_runner - - workspace = _workspace(tmp_path) - step = sizer_builder.build_step( - workspace=workspace, - step_name=StepEnum.TIMING_OPT.value, - input_def=Path("input.def"), - input_verilog=Path("input.v"), - ) - sizer_builder.build_step_space(step) - sizer_builder.build_step_config(workspace, step) - - legalize_module = FakeLegalizeModule() - - def fake_save(*, workspace, step, ecc_module, feature_step): - del workspace, ecc_module, feature_step - Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) - Path(step.output.def_).write_text("partial\n", encoding="utf-8") - raise RuntimeError("geometry snapshot failed") - - monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) - monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) - monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) - monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) - monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) - monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) - - assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete - assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value - assert not Path(step.output.def_).exists() - assert legalize_module.closed is True - - def test_public_sizer_run_marks_invalid_when_tool_missing(tmp_path, monkeypatch): from chipcompiler.tools import run_step as public_run_step from chipcompiler.tools.ecc_sizer import builder as sizer_builder @@ -582,196 +212,3 @@ def test_public_sizer_run_marks_invalid_when_runtime_missing(tmp_path, monkeypat assert _subflow_states(step)["run sizer"] == StateEnum.Invalid.value with open(str(step.script.sizer_env), encoding="utf-8") as file: assert "-tclFile" not in file.read() - - -def test_timing_opt_step_result_does_not_require_gds(tmp_path): - from chipcompiler.engine.flow import EngineFlow - - output_def = tmp_path / "out.def" - output_verilog = tmp_path / "out.v" - output_def.write_text("def\n", encoding="utf-8") - output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") - - step = EccStep( - name=StepEnum.TIMING_OPT.value, - tool="sizer", - output=EccOutput( - def_=output_def, - verilog=output_verilog, - gds=tmp_path / "missing.gds", - ), - ) - - assert EngineFlow(Workspace()).check_step_result(step) is True - - -def test_timing_opt_step_result_requires_declared_geometry_manifest(tmp_path): - from chipcompiler.engine.flow import EngineFlow - - output_def = tmp_path / "out.def" - output_verilog = tmp_path / "out.v" - geometry = tmp_path / "geometry" - output_def.write_text("def\n", encoding="utf-8") - output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") - - step = EccStep( - name=StepEnum.TIMING_OPT.value, - tool="sizer", - output=EccOutput( - def_=output_def, - verilog=output_verilog, - geometry=geometry, - geometry_manifest=geometry / "geometry.manifest", - ), - ) - - assert EngineFlow(Workspace()).check_step_result(step) is False - geometry.mkdir() - (geometry / "geometry.manifest").write_text("schema=ecc.geometry.v1\n") - assert EngineFlow(Workspace()).check_step_result(step) is True - - -def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monkeypatch): - import chipcompiler.tools as tools_api - from chipcompiler.engine import flow as flow_module - from chipcompiler.engine.flow import EngineFlow - - workspace = _workspace(tmp_path) - workspace.flow.path = tmp_path / "flow.json" - # Preferred order is legalization then Timing Opt; a trailing extra - # legalize sibling after Timing Opt is still a valid cached-DB boundary. - workspace.flow.data = { - "steps": [ - { - "name": StepEnum.TIMING_OPT.value, - "tool": "sizer", - "state": StateEnum.Unstart.value, - }, - { - "name": StepEnum.LEGALIZATION.value, - "tool": "ecc", - "state": StateEnum.Unstart.value, - }, - ] - } - - sizer_step = EccStep( - name=StepEnum.TIMING_OPT.value, - tool="sizer", - output=EccOutput( - def_=tmp_path / "sizer.def", - verilog=tmp_path / "sizer.v", - ), - ) - post_sizer_step = EccStep( - name=StepEnum.LEGALIZATION.value, - tool="ecc", - output=EccOutput( - def_=tmp_path / "post.def", - verilog=tmp_path / "post.v", - gds=tmp_path / "post.gds", - ), - ) - pre_sizer_db_closed = [] - - class CloseableDb: - engine = "pre-sizer-db" - - def has_init(self): - return True - - def close(self): - pre_sizer_db_closed.append(True) - - engine_flow = EngineFlow(workspace) - engine_flow.workspace_steps = [sizer_step, post_sizer_step] - monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) - - init_seen = [] - run_seen = [] - - def fake_init_db_engine(): - current_db = engine_flow.engine_db - init_seen.append(None if current_db is None else current_db.engine) - if current_db is None: - assert pre_sizer_db_closed == [True] - monkeypatch.setattr( - engine_flow, - "engine_db", - SimpleNamespace(engine="post-sizer-db", has_init=lambda: True), - ) - return True - - def fake_tool_run(workspace, step, ecc_module): - del workspace - run_seen.append( - ( - step.tool, - ecc_module, - ) - ) - for path in (step.output.def_, step.output.verilog, step.output.gds): - if path is None: - continue - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as file: - file.write("\n") - return StateEnum.Success - - monkeypatch.setattr(engine_flow, "init_db_engine", fake_init_db_engine) - monkeypatch.setattr(tools_api, "run_step", fake_tool_run) - monkeypatch.setattr(tools_api, "save_layout_image", lambda workspace, step: True) - monkeypatch.setattr(flow_module, "log_flow", lambda workspace: None) - - assert engine_flow.run_steps() is True - assert init_seen == ["pre-sizer-db", None] - assert pre_sizer_db_closed == [True] - assert run_seen == [("sizer", "pre-sizer-db"), ("ecc", "post-sizer-db")] - - -def test_engine_flow_clears_cached_db_after_incomplete_sizer_step(tmp_path, monkeypatch): - import chipcompiler.tools as tools_api - from chipcompiler.engine import flow as flow_module - from chipcompiler.engine.flow import EngineFlow - - workspace = _workspace(tmp_path) - workspace.flow.path = tmp_path / "flow.json" - workspace.flow.data = { - "steps": [ - { - "name": StepEnum.TIMING_OPT.value, - "tool": "sizer", - "state": StateEnum.Unstart.value, - } - ] - } - sizer_step = EccStep( - name=StepEnum.TIMING_OPT.value, - tool="sizer", - output=EccOutput( - def_=tmp_path / "sizer.def", - verilog=tmp_path / "sizer.v", - ), - ) - closed = [] - - class CloseableDb: - engine = "pre-sizer-db" - - def has_init(self): - return True - - def close(self): - closed.append(True) - - engine_flow = EngineFlow(workspace) - engine_flow.workspace_steps = [sizer_step] - monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) - monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) - monkeypatch.setattr(tools_api, "run_step", lambda **kwargs: StateEnum.Imcomplete) - monkeypatch.setattr(tools_api, "save_layout_image", lambda workspace, step: True) - monkeypatch.setattr(flow_module, "log_flow", lambda workspace: None) - - assert engine_flow.run_steps() is False - assert closed == [True] - assert engine_flow.engine_db is None diff --git a/test/tools/ecc_sizer/test_runner_cleanup.py b/test/tools/ecc_sizer/test_runner_cleanup.py new file mode 100644 index 000000000..6308ba0ac --- /dev/null +++ b/test/tools/ecc_sizer/test_runner_cleanup.py @@ -0,0 +1,329 @@ +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from chipcompiler.data import StateEnum, StepEnum, Workspace + +from ._sizer_helpers import ( + FakeLegalizeModule, + _fake_sizer_run, + _patch_success_legalize, + _subflow_states, + _workspace, + _write_staging, +) + + +def test_sizer_success_legalize_failure_leaves_published_outputs_empty(tmp_path, monkeypatch): + from chipcompiler.engine.flow import EngineFlow + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("stale\n", encoding="utf-8") + Path(step.output.verilog).write_text("stale\n", encoding="utf-8") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: None) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + states = _subflow_states(step) + assert states["run sizer"] == StateEnum.Success.value + assert states["run legalization"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert not Path(step.output.verilog).exists() + assert sizer_builder.sizer_staging_def(step).is_file() + assert EngineFlow(Workspace()).check_step_result(step) is False + + +def test_sizer_save_data_failure_deletes_partial_outputs(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + return False + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert legalize_module.closed is True + + +def test_sizer_save_data_failure_deletes_feature_report_and_image(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + Path(step.feature.db).write_text("stale feature\n", encoding="utf-8") + Path(step.report.db).write_text("stale report\n", encoding="utf-8") + Path(step.output.image).write_text("stale image\n", encoding="utf-8") + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.feature.db).write_text("partial feature\n", encoding="utf-8") + return False + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert not Path(step.feature.db).exists() + assert not Path(step.report.db).exists() + assert not Path(step.output.image).exists() + assert legalize_module.closed is True + + +def test_sizer_closes_engine_when_published_cleanup_fails(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + return False + + delete_calls = [] + + def exploding_delete(step): + delete_calls.append(step) + if len(delete_calls) > 1: + raise OSError("cannot unlink") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + monkeypatch.setattr(sizer_runner, "_delete_published_outputs", exploding_delete) + + with pytest.raises(OSError, match="cannot unlink"): + sizer_runner.run_step(workspace, step) + assert legalize_module.closed is True + + +def test_sizer_does_not_legalize_when_staging_cleanup_fails(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + _write_staging(step) + + legalize_calls = [] + original_delete = sizer_runner._delete_path + + def fail_staging_delete(path): + if Path(path).name.startswith("sizer."): + raise OSError("cannot unlink staging") + original_delete(path) + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "_delete_path", fail_staging_delete) + + def record_legalize(*args, **kwargs): + del args, kwargs + legalize_calls.append(1) + + monkeypatch.setattr(sizer_runner, "legalize_layout", record_legalize) + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + + with pytest.raises(OSError, match="cannot unlink staging"): + sizer_runner.run_step(workspace, step) + assert legalize_calls == [] + + +def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + _patch_success_legalize(monkeypatch, sizer_runner, step) + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Success + assert _subflow_states(step)["run legalization"] == StateEnum.Success.value + assert _subflow_states(step)["save data"] == StateEnum.Success.value + + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + states = _subflow_states(step) + assert states["run sizer"] == StateEnum.Imcomplete.value + assert states["run legalization"] == StateEnum.Unstart.value + assert states["save data"] == StateEnum.Unstart.value + + +def test_sizer_rerun_does_not_legalize_stale_staging_when_sizer_writes_nothing( + tmp_path, + monkeypatch, +): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + _write_staging(step) + sizer_builder.sizer_staging_def(step).write_text("stale def\n", encoding="utf-8") + + legalize_calls = [] + + def fake_legalize(*args, **kwargs): + legalize_calls.append((args, kwargs)) + return FakeLegalizeModule() + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr( + subprocess, + "run", + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + ) + monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert legalize_calls == [] + assert not sizer_builder.sizer_staging_def(step).exists() + assert not sizer_builder.sizer_staging_verilog(step).exists() + + +def test_sizer_save_data_exception_deletes_partial_outputs(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + + legalize_module = FakeLegalizeModule() + + def fake_save(*, workspace, step, ecc_module, feature_step): + del workspace, ecc_module, feature_step + Path(step.output.def_).parent.mkdir(parents=True, exist_ok=True) + Path(step.output.def_).write_text("partial\n", encoding="utf-8") + raise RuntimeError("geometry snapshot failed") + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_sizer_run(step)) + monkeypatch.setattr(sizer_runner, "legalize_layout", lambda *args, **kwargs: legalize_module) + monkeypatch.setattr(sizer_runner.ecc_runner, "save_data", fake_save) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert _subflow_states(step)["save data"] == StateEnum.Imcomplete.value + assert not Path(step.output.def_).exists() + assert legalize_module.closed is True From 9904e7cdd35297d0f9a3903a22b075389d9f8f2e Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 10:20:13 +0800 Subject: [PATCH 07/90] refactor(tools): keep create_db_engine on step input Restore create_db_engine(workspace, step). Timing Opt inner legalize now replace()s a local step with staging DEF/Verilog and db=None instead of adding loader kwargs. --- chipcompiler/tools/ecc/runner.py | 25 ++++++--------------- chipcompiler/tools/ecc_dreamplace/runner.py | 11 +++++---- test/tools/ecc/test_runner.py | 24 ++++++++------------ test/tools/ecc_dreamplace/test_module.py | 21 ++++++++--------- 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 94b8eb403..65f929dab 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -182,18 +182,8 @@ def _existing_input_path(path: Path | None) -> str | None: return None -def create_db_engine( - workspace: Workspace, - step: WorkspaceStep, - *, - source_def: Path | None = None, - source_verilog: Path | None = None, - skip_input_db: bool = False, -) -> ECCToolsModule | None: - """Load an ECC engine from the step input, or from explicit design files.""" - - def_source = source_def if source_def is not None else step.input.def_ - verilog_source = source_verilog if source_verilog is not None else step.input.verilog +def create_db_engine(workspace: Workspace, step: WorkspaceStep) -> ECCToolsModule | None: + """Load an ECC engine from the step input.""" def _close_engine(ecc_module: ECCToolsModule | None) -> None: if ecc_module is None: @@ -249,8 +239,8 @@ def load_design() -> ECCToolsModule | None: ecc_module.init_techlef(workspace.pdk.tech) ecc_module.init_lefs(workspace.pdk.lefs) - def_path = _existing_input_path(def_source) - verilog_path = _existing_input_path(verilog_source) + def_path = _existing_input_path(step.input.def_) + verilog_path = _existing_input_path(step.input.verilog) if step.name == StepEnum.LVS.value: if def_path is None or not ecc_module.read_def(def_path): @@ -277,15 +267,14 @@ def is_enable_setup() -> bool: return False return ( - _existing_input_path(def_source) is not None - or _existing_input_path(verilog_source) is not None + _existing_input_path(step.input.def_) is not None + or _existing_input_path(step.input.verilog) is not None ) if not is_eda_exist() or not is_enable_setup(): return None - skip_db = skip_input_db or step.name == StepEnum.LVS.value - if skip_db: + if step.name == StepEnum.LVS.value or not step.input.db: return load_design() ecc_module = None diff --git a/chipcompiler/tools/ecc_dreamplace/runner.py b/chipcompiler/tools/ecc_dreamplace/runner.py index 9d714387b..016ed3a1c 100644 --- a/chipcompiler/tools/ecc_dreamplace/runner.py +++ b/chipcompiler/tools/ecc_dreamplace/runner.py @@ -1,8 +1,9 @@ #!/usr/bin/env python +from dataclasses import replace from pathlib import Path -from chipcompiler.data import EccStep, StateEnum, StepEnum, Workspace +from chipcompiler.data import EccStep, StateEnum, StepEnum, StepInput, Workspace from chipcompiler.tools.ecc import EccSubFlow, EccSubFlowEnum, ECCToolsModule from chipcompiler.tools.ecc import runner as ecc_runner @@ -163,13 +164,11 @@ def legalize_layout( ) return None - ecc_module = ecc_runner.create_db_engine( - workspace, + load_step = replace( owner_step, - source_def=input_def, - source_verilog=input_verilog, - skip_input_db=True, + input=StepInput(def_=input_def, verilog=input_verilog, db=None), ) + ecc_module = ecc_runner.create_db_engine(workspace, load_step) if ecc_module is None: logger.error( "Failed to rebuild ECC database for inner legalization of %s", diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 1e22f573f..fdf681dcd 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -228,7 +228,7 @@ def test_create_db_engine_uses_def_input_for_lvs_even_when_db_exists(tmp_path, m assert not any(call[0] == "read_lvs_verilog" for call in module.calls) -def test_create_db_engine_skip_input_db_reads_explicit_sources(tmp_path, monkeypatch): +def test_create_db_engine_reads_replaced_step_input_without_db(tmp_path, monkeypatch): step_def = tmp_path / "step" / "old.def" staging_def = tmp_path / "data" / "to" / "sizer.def.gz" staging_verilog = tmp_path / "data" / "to" / "sizer.v.gz" @@ -247,9 +247,9 @@ def test_create_db_engine_skip_input_db_reads_explicit_sources(tmp_path, monkeyp step = EccStep( name=StepEnum.TIMING_OPT.value, input=StepInput( - def_=step_def, - verilog=tmp_path / "step" / "old.v", - db=tmp_path / "input_db", + def_=staging_def, + verilog=staging_verilog, + db=None, ), data=EccData(dir=tmp_path / "timing_optimization_sizer" / "data"), feature=EccFeature(dir=tmp_path / "timing_optimization_sizer" / "feature"), @@ -260,13 +260,7 @@ def test_create_db_engine_skip_input_db_reads_explicit_sources(tmp_path, monkeyp monkeypatch.setattr(FakeEccModule, "is_db_data_exists", lambda self, path: True) monkeypatch.setattr(FakeEccModule, "load_data", lambda self, path: True) - module = ecc_runner.create_db_engine( - workspace, - step, - source_def=staging_def, - source_verilog=staging_verilog, - skip_input_db=True, - ) + module = ecc_runner.create_db_engine(workspace, step) assert module is FakeEccModule.instances[-1] assert not any(call[0] == "load_data" for call in module.calls) @@ -301,7 +295,7 @@ def failing_read_def(self, path): monkeypatch.setattr(FakeEccModule, "read_def", failing_read_def) - module = ecc_runner.create_db_engine(workspace, step, skip_input_db=True) + module = ecc_runner.create_db_engine(workspace, step) assert module is None constructed = FakeEccModule.instances[-1] @@ -309,7 +303,7 @@ def failing_read_def(self, path): assert constructed.calls[-1] == ("close",) -def test_create_db_engine_skip_input_db_does_not_retry_load_design(tmp_path, monkeypatch): +def test_create_db_engine_without_input_db_does_not_retry_load_design(tmp_path, monkeypatch): design_def = tmp_path / "origin" / "gcd.def" design_def.parent.mkdir() design_def.write_text("VERSION 5.8 ;\nDESIGN gcd ;\nEND DESIGN\n") @@ -323,7 +317,7 @@ def test_create_db_engine_skip_input_db_does_not_retry_load_design(tmp_path, mon ) step = EccStep( name=StepEnum.TIMING_OPT.value, - input=StepInput(def_=design_def, db=tmp_path / "input_db"), + 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"), ) @@ -338,7 +332,7 @@ def __init__(self): monkeypatch.setattr(ecc_runner, "ECCToolsModule", ExplodingModule) with pytest.raises(RuntimeError, match="native init failed"): - ecc_runner.create_db_engine(workspace, step, skip_input_db=True) + ecc_runner.create_db_engine(workspace, step) assert constructions == [1] assert workspace.logger.warnings == [] diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index 1fea19e62..883876245 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -189,15 +189,16 @@ class LocalEcc: def close(self): closed.append(True) - def fake_create_db_engine( - workspace, - owner_step, - *, - source_def=None, - source_verilog=None, - skip_input_db=False, - ): - created.append((source_def, source_verilog, skip_input_db, owner_step.name, workspace)) + def fake_create_db_engine(workspace, load_step): + created.append( + ( + load_step.input.def_, + load_step.input.verilog, + load_step.input.db, + load_step.name, + workspace, + ) + ) return LocalEcc() monkeypatch.setattr(dreamplace_runner, "is_eda_exist", lambda: True) @@ -214,7 +215,7 @@ def fake_create_db_engine( is None ) assert created == [ - (staging_def, staging_verilog, True, StepEnum.TIMING_OPT.value, module.workspace) + (staging_def, staging_verilog, None, StepEnum.TIMING_OPT.value, module.workspace) ] assert closed == [True] From 8bbdc3ffb17bcca2c0454fc1201cad70d8c6cec0 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 10:40:12 +0800 Subject: [PATCH 08/90] fix(tools): invalidate legacy Timing Opt success on subflow rewrite Opening an old one-stage Sizer workspace rewrote subflow stages to Unstart but left the outer step Success, so resume skipped Timing Opt and routed the unlegalized DEF. Persistently unstart the owner and its downstream suffix when that rewrite happens. --- chipcompiler/tools/ecc_sizer/subflow.py | 27 ++++++++ test/tools/ecc_sizer/test_engine_flow.py | 86 +++++++++++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/chipcompiler/tools/ecc_sizer/subflow.py b/chipcompiler/tools/ecc_sizer/subflow.py index 6e045e713..b3cd76a8f 100644 --- a/chipcompiler/tools/ecc_sizer/subflow.py +++ b/chipcompiler/tools/ecc_sizer/subflow.py @@ -45,8 +45,35 @@ def build_sub_flow(self) -> list[dict]: if current != expected: self.workspace_step.subflow.steps = self._canonical_steps() self.save() + self._invalidate_owner_and_suffix() return self.workspace_step.subflow.steps + def _invalidate_owner_and_suffix(self) -> None: + steps = self.workspace.flow.data.get("steps", []) + start = next( + ( + index + for index, step in enumerate(steps) + if isinstance(step, dict) + and step.get("name") == self.workspace_step.name + and step.get("tool") == self.workspace_step.tool + ), + None, + ) + if start is None: + return + for step in steps[start:]: + if not isinstance(step, dict): + continue + step["state"] = StateEnum.Unstart.value + step["runtime"] = "" + step["peak memory (mb)"] = 0 + if self.workspace.flow.path is None: + return + from chipcompiler.utility import json_write + + json_write(self.workspace.flow.path, self.workspace.flow.data) + def reset_stages(self) -> list[dict]: expected = [stage.value for stage in SizerSubFlowEnum] current = [step_dict.get("name") for step_dict in self.workspace_step.subflow.steps or []] diff --git a/test/tools/ecc_sizer/test_engine_flow.py b/test/tools/ecc_sizer/test_engine_flow.py index 63e855f99..6f80084a9 100644 --- a/test/tools/ecc_sizer/test_engine_flow.py +++ b/test/tools/ecc_sizer/test_engine_flow.py @@ -1,9 +1,11 @@ +import json import os +from pathlib import Path from types import SimpleNamespace from chipcompiler.data import EccOutput, EccStep, StateEnum, StepEnum, Workspace -from ._sizer_helpers import _workspace +from ._sizer_helpers import _sizer_runtime, _subflow_states, _workspace def test_timing_opt_step_result_does_not_require_gds(tmp_path): @@ -197,3 +199,85 @@ def close(self): assert engine_flow.run_steps() is False assert closed == [True] assert engine_flow.engine_db is None + + +def test_legacy_one_stage_success_is_invalidated_before_skip(tmp_path, monkeypatch): + from chipcompiler.engine.flow import EngineFlow + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(_sizer_runtime(tmp_path))) + workspace = _workspace(tmp_path) + workspace.flow.path = Path(workspace.directory) / "home" / "flow.json" + workspace.flow.path.parent.mkdir(parents=True, exist_ok=True) + flow_data = { + "steps": [ + { + "name": StepEnum.TIMING_OPT.value, + "tool": "sizer", + "state": StateEnum.Success.value, + }, + { + "name": StepEnum.ROUTING.value, + "tool": "ecc", + "state": StateEnum.Success.value, + }, + ] + } + workspace.flow.path.write_text(json.dumps(flow_data), encoding="utf-8") + workspace.flow.data = flow_data + + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + output_def = Path(step.output.def_) + output_verilog = Path(step.output.verilog) + output_def.parent.mkdir(parents=True, exist_ok=True) + output_def.write_text("unlegalized def\n", encoding="utf-8") + output_verilog.write_text("module gcd; endmodule\n", encoding="utf-8") + assert step.subflow.path is not None + step.subflow.path.write_text( + json.dumps( + { + "path": str(step.subflow.path), + "steps": [ + { + "name": "run sizer", + "state": StateEnum.Success.value, + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ], + } + ), + encoding="utf-8", + ) + + sizer_builder.build_step_config(workspace, step) + assert list(_subflow_states(step)) == [ + "run sizer", + "run legalization", + "save data", + ] + assert set(_subflow_states(step).values()) == {StateEnum.Unstart.value} + persisted = json.loads(workspace.flow.path.read_text(encoding="utf-8")) + assert [item["state"] for item in persisted["steps"]] == [ + StateEnum.Unstart.value, + StateEnum.Unstart.value, + ] + + engine_flow = EngineFlow(workspace) + assert not engine_flow.check_state( + name=StepEnum.TIMING_OPT.value, + tool="sizer", + state=StateEnum.Success, + ) + assert not engine_flow.check_state( + name=StepEnum.ROUTING.value, + tool="ecc", + state=StateEnum.Success, + ) From b994d3d5b51f84293c7b78b6916ec22c03902b72 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 11:36:14 +0800 Subject: [PATCH 09/90] feat(flow): run Timing Opt after post-CTS legalization Insert sizer Timing Opt into the default rtl2gds/harden sequence between legalization and routing so CTS cleanup still happens first and Sizer sees a legal layout. --- chipcompiler/engine/flow.py | 1 + chipcompiler/rtl2gds/builder.py | 1 + docs/architecture.md | 6 +++--- test/rtl2gds/test_builder.py | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index d1e7d6c9a..bc9d4c5e3 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -90,6 +90,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(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)) diff --git a/chipcompiler/rtl2gds/builder.py b/chipcompiler/rtl2gds/builder.py index 5a7af8965..6b12b4f0b 100644 --- a/chipcompiler/rtl2gds/builder.py +++ b/chipcompiler/rtl2gds/builder.py @@ -12,6 +12,7 @@ def build_rtl2gds_flow() -> list: 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((StepEnum.ROUTING, "ecc", StateEnum.Unstart)) steps.append((StepEnum.DRC, "ecc", StateEnum.Unstart)) steps.append((StepEnum.LVS, "ecc", StateEnum.Unstart)) diff --git a/docs/architecture.md b/docs/architecture.md index e930b196f..d3c18e6eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ Routing → input/design.def → ... | `WorkspaceStep` | Per-step workspace: inputs, outputs, configs, logs, reports | | `Parameters` | Design specs: die size, clock frequency, buffer/filler/tie cells | | `PDK` | Tech library paths: LEF, liberty, timing, SPEF | -| `StepEnum` | Flow steps: SYNTHESIS, PLACEMENT, CTS, LEGALIZATION, TIMING_OPT (optional sizer + inner legalize), ROUTING, FILLER | +| `StepEnum` | Flow steps: SYNTHESIS, PLACEMENT, CTS, LEGALIZATION, TIMING_OPT (sizer + inner legalize), ROUTING, FILLER | | `StateEnum` | Step states: Unstart, Ongoing, Success, Incomplete, Invalid, Ignored, Pending | ### Engine Layer (chipcompiler/engine/) @@ -160,9 +160,9 @@ Script `scripts/autopatch-ecc-py.sh` collects `.so` dependencies, copies to `bin ### RTL2GDS Layer (chipcompiler/rtl2gds/) -`build_rtl2gds_flow()` returns complete flow: SYNTHESIS → FLOORPLAN → PLACEMENT → CTS → LEGALIZATION → ROUTING → DRC → FILLER. +`build_rtl2gds_flow()` returns complete flow: SYNTHESIS → FLOORPLAN → PLACEMENT → CTS → LEGALIZATION → TIMING_OPT → ROUTING → DRC → FILLER. -Timing Opt (`TIMING_OPT`, tool `sizer`) is not in the default preset. When a workspace inserts it, it belongs **after legalization and before routing**. CTS dirties legality, so the post-CTS `legalization` sibling still runs first. Sizer then sizes cells and Timing Opt legalizes internally before publishing DEF/Verilog. +Timing Opt (`TIMING_OPT`, tool `sizer`) sits **after legalization and before routing**. CTS dirties legality, so the post-CTS `legalization` sibling still runs first. Sizer then sizes cells and Timing Opt legalizes internally before publishing DEF/Verilog. Missing Sizer does not prevent building the rest of the flow; the Timing Opt step is marked Invalid and later steps still chain from its declared outputs. ### Benchmark Module (benchmark/) diff --git a/test/rtl2gds/test_builder.py b/test/rtl2gds/test_builder.py index 3b37bb769..19e876beb 100644 --- a/test/rtl2gds/test_builder.py +++ b/test/rtl2gds/test_builder.py @@ -60,6 +60,7 @@ def test_build_rtl2gds_flow_includes_lvs_after_drc(): (StepEnum.PLACEMENT, "dreamplace", StateEnum.Unstart), (StepEnum.CTS, "ecc", StateEnum.Unstart), (StepEnum.LEGALIZATION, "dreamplace", StateEnum.Unstart), + (StepEnum.TIMING_OPT, "sizer", StateEnum.Unstart), (StepEnum.ROUTING, "ecc", StateEnum.Unstart), (StepEnum.DRC, "ecc", StateEnum.Unstart), (StepEnum.LVS, "ecc", StateEnum.Unstart), From 93b6a2aab80fb7eb329f48a77dae652251e640bc Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 15:07:53 +0800 Subject: [PATCH 10/90] ci: install Sizer from ecc-sizer CI artifacts Download the latest successful linux-x64 Sizer build before pytest and export PATH plus CHIPCOMPILER_ECC_SIZER_ROOT so default-flow Timing Opt can run in CI. --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++++++++++++ test/data/test_workspace.py | 1 + 2 files changed, 45 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aea45045..688d40be0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,50 @@ jobs: yosys -V yosys -p "plugin -i slang" + - name: Setup Sizer + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + run_id="$( + gh api "repos/openecos-projects/ecc-sizer/actions/workflows/ci.yml/runs?branch=main&status=success&per_page=20" \ + --jq '[.workflow_runs[] | select(.conclusion == "success") | .id][0] // empty' + )" + if [ -z "$run_id" ]; then + echo "::error::No successful ecc-sizer CI artifact found on main" + exit 1 + fi + + install_root="${RUNNER_TEMP}/ecc-sizer" + mkdir -p "$install_root" + echo "Downloading ecc-sizer-linux-x64 from openecos-projects/ecc-sizer CI run ${run_id}" + gh run download "$run_id" \ + --repo openecos-projects/ecc-sizer \ + --name ecc-sizer-linux-x64 \ + --dir "$install_root" + + archive="$(find "$install_root" -name 'ecc-sizer-linux-x64.tar.gz' -print -quit)" + if [ -z "$archive" ]; then + echo "::error::ecc-sizer-linux-x64.tar.gz was not in the downloaded artifact" + exit 1 + fi + tar -xzf "$archive" -C "$install_root" + + sizer_root="$(find "$install_root" -type f -path '*/src/sizer_os.tcl' -printf '%h\n' | sed 's#/src$##' | head -n 1)" + if [ -z "$sizer_root" ] || [ ! -x "${sizer_root}/bin/Sizer" ]; then + echo "::error::Downloaded ecc-sizer artifact is missing bin/Sizer or src/sizer_os.tcl" + exit 1 + fi + + echo "${sizer_root}/bin" >> "$GITHUB_PATH" + echo "CHIPCOMPILER_ECC_SIZER_ROOT=${sizer_root}" >> "$GITHUB_ENV" + + - name: Verify Sizer + run: | + command -v Sizer + test -f "${CHIPCOMPILER_ECC_SIZER_ROOT}/src/sizer_os.tcl" + - name: Pytest run: uv run --no-sync pytest test/ --ignore=test/examples/test_soc.py --cov=chipcompiler --cov-report= diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py index d6dff0e10..87fd7f31f 100644 --- a/test/data/test_workspace.py +++ b/test/data/test_workspace.py @@ -215,6 +215,7 @@ def test_create_workspace_derives_dynamic_flow_from_boundaries( "place", "CTS", "legalization", + "Timing optimization", "route", "drc", "lvs", From 9a3e44cd8ba48af82d856577e0f975e0860448cc Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 16:25:28 +0800 Subject: [PATCH 11/90] fix(tools): keep Sizer errors in the Timing Opt step log Stop opening step.log.file with write truncation. Sizer now inherits the EngineFlow stdio capture so GUI still shows the sizer error when inner legalize later writes its own DreamPlace log. --- chipcompiler/tools/ecc_sizer/runner.py | 15 +++++----- test/tools/ecc_sizer/test_runner.py | 40 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index c98afc582..e495fc1ac 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -117,14 +117,13 @@ def run_step( sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Ongoing) command = get_sizer_command() + ["-env", str(env_path), "-f", str(cmd_path)] - with open(log_path, "w", encoding="utf-8") as log_file: - result = subprocess.run( - command, - cwd=str(output_dir), - stdout=log_file, - stderr=subprocess.STDOUT, - check=False, - ) + result = subprocess.run( + command, + cwd=str(output_dir), + stdout=None, + stderr=subprocess.STDOUT, + check=False, + ) if result.returncode != 0 or not _has_staging_outputs(step): logger.error( diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index d88c9f879..4981bb79b 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -32,7 +32,7 @@ def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, mon calls = [] def fake_run(command, cwd, stdout, stderr, check): - calls.append((command, cwd, stderr, check)) + calls.append((command, cwd, stdout, stderr, check)) _write_staging(step) return SimpleNamespace(returncode=0) @@ -69,6 +69,7 @@ def fake_run(command, cwd, stdout, stderr, check): str(step.script.sizer_cmd), ], str(step.data.steps[StepEnum.TIMING_OPT.value]), + None, subprocess.STDOUT, False, ) @@ -165,6 +166,43 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( assert _subflow_states(step)["run sizer"] == StateEnum.Imcomplete.value +def test_sizer_runner_inherits_captured_stdio_instead_of_truncating_step_log( + tmp_path, + monkeypatch, +): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import runner as sizer_runner + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def=Path("input.def"), + input_verilog=Path("input.v"), + ) + sizer_builder.build_step_space(step) + sizer_builder.build_step_config(workspace, step) + Path(step.log.file).write_text("preface\n", encoding="utf-8") + seen = {} + + def fake_run(command, cwd, stdout, stderr, check): + del command, cwd, check + seen["stdout"] = stdout + seen["stderr"] = stderr + return SimpleNamespace(returncode=1) + + monkeypatch.setattr(sizer_runner, "get_sizer_command", lambda: ["/fake/sizer"]) + monkeypatch.setattr(sizer_runner, "is_eda_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) + monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) + monkeypatch.setattr(subprocess, "run", fake_run) + + assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete + assert seen["stdout"] is None + assert seen["stderr"] is subprocess.STDOUT + assert Path(step.log.file).read_text(encoding="utf-8") == "preface\n" + + def test_public_sizer_run_marks_invalid_when_tool_missing(tmp_path, monkeypatch): from chipcompiler.tools import run_step as public_run_step from chipcompiler.tools.ecc_sizer import builder as sizer_builder From 2fd2327b0dc5bcae4664df7b407e8e5256d2b6ee Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 1 Sep 2026 17:25:47 +0800 Subject: [PATCH 12/90] feat(tools): emit Timing Opt QoR from the legalized DB Sizer now exposes build_step_metrics so EngineFlow can write qor_metrics/summary after a successful Timing Opt. Reuse the legalization DB summary and skip the Sizer binary check so QoR refresh still works when Sizer is only needed at run time. --- chipcompiler/tools/ecc_sizer/__init__.py | 2 + chipcompiler/tools/ecc_sizer/metrics.py | 7 +++ chipcompiler/tools/eda.py | 2 +- test/tools/ecc_sizer/test_module.py | 54 ++++++++++++++++++++++++ test/tools/test_eda_loader.py | 18 ++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 chipcompiler/tools/ecc_sizer/metrics.py diff --git a/chipcompiler/tools/ecc_sizer/__init__.py b/chipcompiler/tools/ecc_sizer/__init__.py index 4aa46ea96..f93163163 100644 --- a/chipcompiler/tools/ecc_sizer/__init__.py +++ b/chipcompiler/tools/ecc_sizer/__init__.py @@ -5,6 +5,7 @@ sizer_staging_def, sizer_staging_verilog, ) +from .metrics import build_step_metrics from .runner import run_step from .service import get_step_info from .utility import ( @@ -18,6 +19,7 @@ __all__ = [ "build_step", "build_step_config", + "build_step_metrics", "build_step_space", "get_step_info", "find_sizer_root", diff --git a/chipcompiler/tools/ecc_sizer/metrics.py b/chipcompiler/tools/ecc_sizer/metrics.py new file mode 100644 index 000000000..44e158b2b --- /dev/null +++ b/chipcompiler/tools/ecc_sizer/metrics.py @@ -0,0 +1,7 @@ +from chipcompiler.data import EccStep, StepMetrics, Workspace + + +def build_step_metrics(workspace: Workspace, step: EccStep) -> StepMetrics | None: + from chipcompiler.tools.ecc.metrics import build_metrics_legalization + + return build_metrics_legalization(workspace, step) diff --git a/chipcompiler/tools/eda.py b/chipcompiler/tools/eda.py index 6c49d51cb..029ab24e6 100644 --- a/chipcompiler/tools/eda.py +++ b/chipcompiler/tools/eda.py @@ -125,7 +125,7 @@ def build_step_metrics(workspace: Workspace, step: WorkspaceStep) -> StepMetrics """ build step metrics """ - eda_module = load_eda_module(step.tool) + eda_module = load_eda_module(step.tool, check_dependency=step.tool != "sizer") build_metrics = getattr(eda_module, "build_step_metrics", None) if build_metrics is None: return None diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index e79ab6c79..7fdc3b4cb 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -76,6 +76,60 @@ def test_sizer_step_config_writes_env_and_cmd_files(tmp_path, monkeypatch): assert checklist["checklist"] == [] +def test_sizer_metrics_write_qor_files_from_db_summary(tmp_path): + from chipcompiler.tools.ecc_sizer import builder as sizer_builder + from chipcompiler.tools.ecc_sizer import metrics as sizer_metrics + + workspace = _workspace(tmp_path) + step = sizer_builder.build_step( + workspace=workspace, + step_name=StepEnum.TIMING_OPT.value, + input_def="input.def", + input_verilog="input.v", + ) + sizer_builder.build_step_space(step) + sizer_builder.build_sub_flow(workspace, step) + assert step.feature.db is not None + step.feature.db.write_text( + json.dumps( + { + "Design Layout": { + "die_area": 1200.5, + "die_bounding_width": 40, + "die_bounding_height": 30, + "die_usage": 0.4, + "core_area": 900.25, + "core_usage": 0.5, + }, + "Design Statis": { + "num_iopins": 12, + "num_instances": 100, + "num_nets": 80, + }, + } + ), + encoding="utf-8", + ) + + metrics = sizer_metrics.build_step_metrics(workspace, step) + + assert metrics is not None + assert step.analysis.qor_metrics is not None + assert step.analysis.qor_metrics.is_file() + 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 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) + assert [item["name"] for item in subflow["steps"]] == [ + "run sizer", + "run legalization", + "save data", + ] + + def test_sizer_config_preserves_runtime_parseable_order(tmp_path, monkeypatch): from chipcompiler.tools.ecc_sizer import builder as sizer_builder diff --git a/test/tools/test_eda_loader.py b/test/tools/test_eda_loader.py index 8da2ebb3e..16a73e3f4 100644 --- a/test/tools/test_eda_loader.py +++ b/test/tools/test_eda_loader.py @@ -42,3 +42,21 @@ def test_build_step_metrics_returns_none_when_tool_dependency_missing(monkeypatc metrics = eda.build_step_metrics(SimpleNamespace(), SimpleNamespace(tool="missing_eda")) assert metrics is None + + +def test_build_step_metrics_loads_sizer_without_binary_dependency(monkeypatch): + seen = {} + + def build_metrics(workspace, step): + seen["workspace"] = workspace + seen["step"] = step + return StepMetrics(path="analysis/qor_metrics.json", data={"die_area": 1}) + + _install_tool_module(monkeypatch, "ecc_sizer", exists=False, build_metrics=build_metrics) + workspace = SimpleNamespace(name="workspace") + step = SimpleNamespace(tool="sizer") + + metrics = eda.build_step_metrics(workspace, step) + + assert metrics == StepMetrics(path="analysis/qor_metrics.json", data={"die_area": 1}) + assert seen == {"workspace": workspace, "step": step} From edccecba1834f7013979c8203cd5730b2792181b Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 11:05:06 +0800 Subject: [PATCH 13/90] feat: add asynchronous candidate rerun operations --- agent/engine.py | 31 ++++++++++- agent/requests.py | 2 + agent/server.py | 7 +++ agent/test/test_requests.py | 74 +++++++++++++++++++++++++ agent/test/test_workspace_api.py | 80 +++++++++++++++++++++++++--- agent/workspace_api.py | 80 ++++++++++++++++++++++++---- chipcompiler/cli/commands/rpc.py | 6 ++- chipcompiler/runtime/stdio_server.py | 8 ++- 8 files changed, 266 insertions(+), 22 deletions(-) diff --git a/agent/engine.py b/agent/engine.py index ebf9232ea..5e44a3f03 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -4,7 +4,11 @@ from threading import Event, Thread from chipcompiler.data import StateEnum, WorkspaceStep -from chipcompiler.engine.flow import EngineFlow +from chipcompiler.engine.flow import ( + EngineFlow, + _notify_flow_observer, + _wait_for_step_rendered, +) from chipcompiler.engine.step_execution import get_process_rss_mb, track_current_process_memory from chipcompiler.utility.log import redirect_stdio_to_file @@ -21,7 +25,13 @@ def build_default_steps(self): steps.insert(filler_index, self.init_flow_step("DRC", "ecc", StateEnum.Unstart)) self.save() - def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) -> StateEnum: + def run_step( + self, + workspace_step: WorkspaceStep | str, + *, + rerun: bool = False, + observer=None, + ) -> StateEnum: if isinstance(workspace_step, str): workspace_step = self.get_workspace_step(workspace_step) if workspace_step is None: @@ -32,6 +42,7 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) ): self.workspace.logger.info("[SKIP] %s already succeeded", step_tag) self.clear_db_engine_after_step(workspace_step, StateEnum.Success) + _notify_flow_observer(observer, "on_step_skipped", workspace_step) return StateEnum.Success self._normalize_legacy_terminal_state(workspace_step, step_tag) @@ -39,9 +50,13 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) start_time = time.time() timing_constraints = self.timing_constraint_facts() self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) + _notify_flow_observer(observer, "on_step_started", workspace_step) self._redirect_step_stdio(workspace_step) start_memory, peak_memory, stop_monitor, monitor = self._start_memory_monitor() result = False + previous_observer = getattr(self.workspace, "_runtime_flow_observer", None) + if observer is not None: + self.workspace._runtime_flow_observer = observer try: result = run_agent_step( workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine @@ -52,6 +67,11 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) traceback.print_exc() finally: self._stop_memory_monitor(stop_monitor, monitor) + if observer is not None: + if previous_observer is None: + delattr(self.workspace, "_runtime_flow_observer") + else: + self.workspace._runtime_flow_observer = previous_observer elapsed = time.time() - start_time state = self._step_state(workspace_step, result) @@ -62,6 +82,13 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) timing_constraints, max(0, round(peak_memory[0] - start_memory, 3)), ) + _notify_flow_observer(observer, "on_step_completed", workspace_step, state) + if state == StateEnum.Success and not _wait_for_step_rendered( + observer, + workspace_step, + state, + ): + return StateEnum.Invalid return state def _redirect_step_stdio(self, workspace_step: WorkspaceStep) -> None: diff --git a/agent/requests.py b/agent/requests.py index 30be9d4a2..6f02671f3 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -33,6 +33,7 @@ class CandidateRerunRequest: candidate_id: str patch: list[dict[str, Any]] execution_scope: str + idempotency_key: str _FIELD_ALIASES = { @@ -42,6 +43,7 @@ class CandidateRerunRequest: "sourceStep": "source_step", "candidateId": "candidate_id", "executionScope": "execution_scope", + "idempotencyKey": "idempotency_key", } diff --git a/agent/server.py b/agent/server.py index d5a1b0ef2..51a4732b8 100644 --- a/agent/server.py +++ b/agent/server.py @@ -1,5 +1,6 @@ from jsonrpcserver import Error +from chipcompiler.runtime.requests import RequestValidationError from chipcompiler.runtime.server import ERROR_CODES, RuntimeServer from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi @@ -34,6 +35,12 @@ def dispatch(**params): try: request = parse_agent_request_model(spec.request_model, params) return handler(request) + except RequestValidationError as exc: + return Error( + ERROR_CODES["invalid_request"], + "invalid_request", + {"message": exc.reason}, + ) except RuntimeApiError as exc: return Error( ERROR_CODES.get(exc.code, -32000), diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index a27f6ff5e..d530e1559 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -1,9 +1,14 @@ +import json +import subprocess +import sys + import pytest from agent.methods import agent_method_names from agent.requests import CandidateRerunRequest, parse_agent_request_model from agent.server import AgentRuntimeServer from chipcompiler.runtime.requests import RequestValidationError +from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame def test_agent_methods_keep_the_original_rpc_names(): @@ -32,6 +37,7 @@ def test_agent_request_normalizes_camel_case_fields(): "candidateId": "candidate-1", "patch": [], "executionScope": "full_flow", + "idempotencyKey": "episode-1.intervention-1", }, ) @@ -42,6 +48,7 @@ def test_agent_request_normalizes_camel_case_fields(): candidate_id="candidate-1", patch=[], execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", ) @@ -59,3 +66,70 @@ def test_agent_request_rejects_duplicate_aliases(): "executionScope": "single_step", }, ) + + +def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): + server = AgentRuntimeServer() + + response = json.loads( + server.dispatch( + json.dumps( + { + "jsonrpc": "2.0", + "method": "candidate.rerun", + "id": 1, + "params": { + "workspaceId": "workspace-1", + "targetStep": "place", + "endStep": "route", + "candidateId": "candidate-1", + "patch": [ + {"knob_id": "place.target_density", "value": 0.6}, + {"knob_id": "place.routability_opt", "value": True}, + ], + "executionScope": "full_flow", + "idempotencyKey": "episode-1.intervention-1", + }, + } + ) + ) + ) + + assert response["error"] == { + "code": -32602, + "message": "invalid_request", + "data": {"message": "candidate rerun requires exactly one patch item"}, + } + + +def test_agent_rpc_cli_is_explicitly_opt_in(): + def request(method: str, request_id: int, params: dict | None = None) -> bytes: + payload = {"jsonrpc": "2.0", "method": method, "id": request_id} + if params is not None: + payload["params"] = params + return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) + + def capabilities(*, agent_enabled: bool) -> list[str]: + command = [ + sys.executable, + "-m", + "chipcompiler.cli.main", + "rpc", + "serve", + "--stdio", + ] + if agent_enabled: + command.append("--agent") + completed = subprocess.run( + command, + input=request("rpc.hello", 1, {"version": 1}) + request("rpc.shutdown", 2), + capture_output=True, + check=False, + ) + decoder = ContentLengthDecoder() + responses = [json.loads(message) for message in decoder.feed(completed.stdout)] + assert completed.returncode == 0, completed.stderr.decode("utf-8", errors="replace") + return responses[0]["result"]["capabilities"] + + assert "candidate.rerun" not in capabilities(agent_enabled=False) + assert "candidate.rerun" in capabilities(agent_enabled=True) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index f1106b931..7af63975f 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -1,10 +1,15 @@ +import threading from pathlib import Path from types import SimpleNamespace +import pytest + from agent.requests import CandidateRerunRequest from agent.workspace_api import FlowAgentRuntimeApi, _candidate_step_artifact_dirs from chipcompiler.data import StateEnum from chipcompiler.data.workspace.layout import EccOutput +from chipcompiler.runtime.operations import RuntimeOperationManager +from chipcompiler.runtime.workspace_api import RuntimeApiError def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): @@ -18,7 +23,9 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) -def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatch, tmp_path): +def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( + monkeypatch, tmp_path +): workspace = SimpleNamespace( directory=tmp_path, flow=SimpleNamespace( @@ -86,14 +93,29 @@ def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatc candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", ) ) - assert result == { - "target_step": "place", - "end_step": "CTS", - "execution_scope": "full_flow", - } + assert result["operationId"].startswith("operation-") + assert result["kind"] == "candidate_rerun" + assert result["origin"] == "agent" + assert result["rerun"] is True + assert result["step"] == "place" + duplicate = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + ) + ) + assert duplicate["operationId"] == result["operationId"] + assert duplicate["deduplicated"] is True + _wait_for_terminal(api.ecc_api.operations, result["operationId"]) assert calls == [ ("bind", "place", "Floorplan", "candidate-1"), ( @@ -112,9 +134,39 @@ def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatc assert not list(cts_output.iterdir()) +def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): + workspace = SimpleNamespace(directory=tmp_path) + ecc_api = _EccApi(workspace) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="exactly one patch item"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[ + {"knob_id": "place.target_density", "value": 0.6}, + {"knob_id": "place.routability_opt", "value": True}, + ], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + class _EccApi: def __init__(self, workspace): self.session = SimpleNamespace(workspace=workspace, db_handle=None) + self.events = [] + self.operations = RuntimeOperationManager(self.events.append) + + def _get_session(self, workspace_id): + assert workspace_id == "workspace-1" + return self.session def _with_session_mutation_lock(self, workspace_id, operation): assert workspace_id == "workspace-1" @@ -146,6 +198,20 @@ def get_step(self, name, tool): def save(self): return True - def run_step(self, step, *, rerun): + def run_step(self, step, *, rerun, observer=None): self.run_calls.append((step.name, rerun)) + if observer is not None: + observer.on_step_started(step) + observer.on_step_completed(step, StateEnum.Success) return StateEnum.Success + + +def _wait_for_terminal(operations, operation_id): + deadline = threading.Event() + for _ in range(100): + status = operations.operation_status(operation_id) + if status["state"] in {"succeeded", "failed", "cancelled"}: + assert status["state"] == "succeeded" + return status + deadline.wait(0.01) + raise AssertionError("candidate operation did not reach a terminal state") diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 9c310a17d..fd28c174c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -1,8 +1,10 @@ import json +import re import shutil from hashlib import sha256 from pathlib import Path +from chipcompiler.runtime.operations import RuntimeOperationConflict from chipcompiler.runtime.requests import WorkspaceIdRequest from chipcompiler.runtime.workspace_api import ( RuntimeApiError, @@ -90,12 +92,25 @@ def materialize_candidate(self, request: CandidateMaterializeRequest) -> dict: ) def candidate_rerun(self, request: CandidateRerunRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: self._candidate_rerun(session, request), - ) + _validate_candidate_rerun_request(request) + self.ecc_api._get_session(request.workspace_id) + try: + return self.ecc_api.operations.start( + workspace_id=request.workspace_id, + kind="candidate_rerun", + origin="agent", + rerun=True, + step=request.target_step, + idempotency_key=request.idempotency_key, + runner=lambda observer: self._with_workspace_lock( + request.workspace_id, + lambda session: self._candidate_rerun(session, request, observer), + ), + ) + except RuntimeOperationConflict as exc: + raise RuntimeApiError("command_failed", str(exc)) from exc - def _candidate_rerun(self, session, request: CandidateRerunRequest) -> dict: + def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: should_capture = self.ecc_api._should_capture_session_db(session) previous_db = session.db_handle if should_capture else None if should_capture: @@ -112,14 +127,16 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest) -> dict: if request.patch: _materialize_candidate_rerun(session.workspace, flow, request) _prepare_candidate_rerun(session.workspace, flow, steps) + _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: _reapply_candidate_input(session.workspace, flow, request.target_step) for step in steps: - _run_candidate_step(flow, step) + _run_candidate_step(flow, step, observer=observer) return { - "end_step": request.end_step, - "execution_scope": request.execution_scope, - "target_step": request.target_step, + "candidateId": request.candidate_id, + "endStep": request.end_step, + "executionScope": request.execution_scope, + "targetStep": request.target_step, } finally: self._finish_flow( @@ -184,6 +201,47 @@ def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scop return steps[target_index : end_index + 1] +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") + + +def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: + for name in ("workspace_id", "target_step", "end_step", "candidate_id"): + value = getattr(request, name) + if not isinstance(value, str) or not value.strip(): + raise RuntimeApiError("invalid_request", f"candidate rerun {name} is invalid") + if request.execution_scope != "full_flow": + raise RuntimeApiError( + "invalid_request", "candidate rerun execution scope must be full_flow" + ) + if not isinstance(request.patch, list) or len(request.patch) != 1: + raise RuntimeApiError("invalid_request", "candidate rerun requires exactly one patch item") + patch_item = request.patch[0] + if not isinstance(patch_item, dict) or set(patch_item) != {"knob_id", "value"}: + raise RuntimeApiError( + "invalid_request", "candidate rerun patch item must contain only knob_id and value" + ) + if not isinstance(patch_item["knob_id"], str) or not patch_item["knob_id"]: + raise RuntimeApiError("invalid_request", "candidate rerun knob_id is invalid") + try: + json.dumps(patch_item["value"], allow_nan=False) + except (TypeError, ValueError) as exc: + raise RuntimeApiError("invalid_request", "candidate rerun value is not JSON") from exc + if not isinstance(request.idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch( + request.idempotency_key + ): + raise RuntimeApiError("invalid_request", "candidate rerun idempotency key is invalid") + + +def _notify_candidate_rerun_prepared(observer, steps: list, request: CandidateRerunRequest) -> None: + callback = getattr(observer, "on_rerun_prepared", None) + if callable(callback): + callback( + affected_steps=[str(step.name) for step in steps], + scope=request.execution_scope, + target_step=request.target_step, + ) + + def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: source_step = _candidate_source_step(flow, request.target_step) bind_candidate_input( @@ -261,9 +319,9 @@ def _clear_candidate_artifact_dir(workspace_root: Path, directory: Path, step_na directory.mkdir(parents=True, exist_ok=True) -def _run_candidate_step(flow, step) -> None: +def _run_candidate_step(flow, step, *, observer) -> None: _init_db_engine_for_workspace_step(flow, step) - state = flow.run_step(step, rerun=True) + state = flow.run_step(step, rerun=True, observer=observer) if _state_value(state) != "Success": raise RuntimeApiError( "command_failed", diff --git a/chipcompiler/cli/commands/rpc.py b/chipcompiler/cli/commands/rpc.py index 48b46556f..16b618a4e 100644 --- a/chipcompiler/cli/commands/rpc.py +++ b/chipcompiler/cli/commands/rpc.py @@ -24,10 +24,14 @@ def serve_cmd( help="Enable explicit persistent DB lifecycle RPC methods.", ), ] = False, + agent: Annotated[ + bool, + typer.Option("--agent", help="Enable the fixed Flow Agent RPC methods."), + ] = False, ) -> None: if not stdio: raise typer.BadParameter("--stdio is required", param_hint="--stdio") from chipcompiler.runtime.stdio_server import main - raise typer.Exit(code=main(persistent_db_enabled=persistent_db)) + raise typer.Exit(code=main(persistent_db_enabled=persistent_db, agent_enabled=agent)) diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index de0f9ced6..92ccc0af8 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -112,9 +112,15 @@ def _read_chunk(input_stream: BinaryIO) -> bytes: return input_stream.read(8192) -def main(*, persistent_db_enabled: bool = False) -> int: +def main(*, persistent_db_enabled: bool = False, agent_enabled: bool = False) -> int: + server = RuntimeServer(persistent_db_enabled=persistent_db_enabled) + if agent_enabled: + from agent.server import AgentRuntimeServer + + server = AgentRuntimeServer(persistent_db_enabled=persistent_db_enabled) return run_stdio_server( sys.stdin.buffer, sys.stdout.buffer, + server=server, persistent_db_enabled=persistent_db_enabled, ) From afa871ce1921025306ff7ef0ebf44169ffc15550 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 11:30:02 +0800 Subject: [PATCH 14/90] feat: default runtime workspaces to harden flow --- chipcompiler/runtime/workspace_api.py | 2 +- test/runtime/test_workspace_api.py | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index b06fccd4e..53cf7968e 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -2051,7 +2051,7 @@ 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(): + for step, tool, state in rtl2gds_api.build_harden_flow(): engine_flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index 4c34f3064..a71a28cd6 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -206,7 +206,7 @@ def fake_load_workspace(directory): ) monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( - "chipcompiler.rtl2gds.build_rtl2gds_flow", + "chipcompiler.rtl2gds.build_harden_flow", lambda: [("Synthesis", "yosys", "Unstart")], ) @@ -219,6 +219,25 @@ def fake_load_workspace(directory): return capture, ws +def test_runtime_workspace_defaults_to_harden_flow(monkeypatch): + from chipcompiler.runtime.workspace_api import build_flow_for_workspace + + workspace = SimpleNamespace(flow=SimpleNamespace(data={})) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("rtl2gds", "ecc", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_harden_flow", + lambda: [("Harden", "ecc", "Unstart")], + ) + + flow = build_flow_for_workspace(workspace) + + assert flow.added_steps == [("Harden", "ecc", "Unstart")] + + def _assert_call_waits_for_session_lock(api, workspace_id, call, entered): session = api.sessions.get_session(workspace_id) result_queue = queue.Queue() From d29c6645f195ba0c0bf944c709c214645a569cb1 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 11:39:32 +0800 Subject: [PATCH 15/90] fix: align agent reruns with harden flow --- agent/test/test_workspace_api.py | 36 +++++++++++++++++++++++++++++++- agent/workspace_api.py | 2 +- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 7af63975f..19501390e 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -5,7 +5,11 @@ import pytest from agent.requests import CandidateRerunRequest -from agent.workspace_api import FlowAgentRuntimeApi, _candidate_step_artifact_dirs +from agent.workspace_api import ( + FlowAgentRuntimeApi, + _candidate_step_artifact_dirs, + build_agent_flow_for_workspace, +) from chipcompiler.data import StateEnum from chipcompiler.data.workspace.layout import EccOutput from chipcompiler.runtime.operations import RuntimeOperationManager @@ -23,6 +27,36 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) +def test_agent_flow_defaults_to_harden_flow(monkeypatch): + class RecordingFlow: + def __init__(self, workspace): + self.workspace = workspace + self.added_steps = [] + + def has_init(self): + return False + + def add_step(self, step, tool, state): + self.added_steps.append((step, tool, state)) + + def create_step_workspaces(self): + return None + + monkeypatch.setattr("agent.workspace_api.AgentEngineFlow", RecordingFlow) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("rtl2gds", "ecc", "Unstart")], + ) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_harden_flow", + lambda: [("Harden", "ecc", "Unstart")], + ) + + flow = build_agent_flow_for_workspace(SimpleNamespace()) + + assert flow.added_steps == [("Harden", "ecc", "Unstart")] + + def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( monkeypatch, tmp_path ): diff --git a/agent/workspace_api.py b/agent/workspace_api.py index fd28c174c..6fe71e3de 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -36,7 +36,7 @@ def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = flow = AgentEngineFlow(workspace=workspace) if not flow.has_init(): - for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): + for step, tool, state in rtl2gds_api.build_harden_flow(): flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: flow.create_step_workspaces() From 5508a5448a6bac7ba6813d685fe8f8c9299f994a Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 11:57:13 +0800 Subject: [PATCH 16/90] fix: protect stdio rpc from background output --- chipcompiler/runtime/stdio_server.py | 11 ++++++ test/runtime/test_stdio_server.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index 92ccc0af8..df5904cd6 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -68,6 +68,16 @@ def _write_all(fd: int, data: bytes) -> None: view = view[written:] +def _redirect_process_stdout_to_stderr(output_stream: BinaryIO) -> None: + try: + if output_stream.fileno() != sys.stdout.fileno(): + return + sys.stdout.flush() + os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) + except (AttributeError, OSError): + return + + def run_stdio_server( input_stream: BinaryIO, output_stream: BinaryIO, @@ -78,6 +88,7 @@ def run_stdio_server( runtime_server = server or RuntimeServer(persistent_db_enabled=persistent_db_enabled) decoder = ContentLengthDecoder() writer = _ProtocolWriter(output_stream) + _redirect_process_stdout_to_stderr(output_stream) runtime_server.set_notification_sink(writer.send_notification) try: diff --git a/test/runtime/test_stdio_server.py b/test/runtime/test_stdio_server.py index 4fb400f37..44b61c6c9 100644 --- a/test/runtime/test_stdio_server.py +++ b/test/runtime/test_stdio_server.py @@ -4,6 +4,8 @@ import select import subprocess import sys +import textwrap +import time from pathlib import Path from chipcompiler.data import create_workspace @@ -171,6 +173,60 @@ def test_rpc_stdio_subprocess_smoke(): assert responses[1]["result"] == {"ok": True} +def test_rpc_stdio_subprocess_keeps_background_stdout_away_from_protocol(): + program = textwrap.dedent( + """ + import sys + import threading + import time + + from chipcompiler.runtime.server import RuntimeServer + from chipcompiler.runtime.stdio_server import run_stdio_server + + server = RuntimeServer() + + def background_print(): + time.sleep(0.1) + print("background noise") + + def start_background_print(): + threading.Thread(target=background_print, daemon=True).start() + return {"ok": True} + + server.dispatcher.add_method("test.backgroundPrint", start_background_print) + raise SystemExit(run_stdio_server(sys.stdin.buffer, sys.stdout.buffer, server=server)) + """ + ) + process = subprocess.Popen( + [sys.executable, "-c", program], + cwd=os.getcwd(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + _write_subprocess_request(process, "test.backgroundPrint", 1) + assert _read_subprocess_response(process)["result"] == {"ok": True} + time.sleep(0.2) + _write_subprocess_request(process, "rpc.ping", 2) + assert _read_subprocess_response(process) == { + "jsonrpc": "2.0", + "result": {"ok": True}, + "id": 2, + } + _write_subprocess_request(process, "rpc.shutdown", 3) + assert _read_subprocess_response(process)["result"] == {"ok": True} + stderr = process.communicate(timeout=5)[1] + finally: + if process.poll() is None: + process.kill() + process.communicate() + + assert process.returncode == 0, stderr.decode("utf-8", errors="replace") + assert "background noise" in stderr.decode("utf-8", errors="replace") + + def test_rpc_stdio_subprocess_persistent_db_smoke(): stdin = _request("rpc.hello", 1, {"version": 1}) + _request("rpc.shutdown", 2) From d7d4b78b37acc6e50714b7ce0fae94a38d4e7e6b Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 12:12:20 +0800 Subject: [PATCH 17/90] feat: preserve candidate config snapshots --- agent/data/candidate_materialization.py | 95 +++++++++++++++++++ .../data/test_candidate_materialization.py | 34 +++++++ 2 files changed, 129 insertions(+) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index d6014592f..0d9a9319e 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -1,6 +1,7 @@ """Controlled, replayable config overlays for isolated ECC candidate workspaces.""" import math +from copy import deepcopy from pathlib import Path from typing import Any @@ -40,8 +41,10 @@ def materialize_candidate_config( normalized_patch = _normalize_patch(patch) knobs = _resolve_knobs(target_step, normalized_patch, workspace) configs, config_paths, before_hashes = _load_configs(workspace, knobs) + before_configs = deepcopy(configs) _apply_patch(configs, knobs, normalized_patch) after_hashes = _write_configs(workspace, configs, config_paths) + snapshots = _write_config_snapshots(workspace, candidate_id, before_configs, configs) receipt = _build_receipt( workspace, target_step, @@ -51,6 +54,7 @@ def materialize_candidate_config( config_paths, before_hashes, after_hashes, + snapshots, ) write_json_atomic(_receipt_path(workspace), receipt) return receipt @@ -69,8 +73,15 @@ def reapply_materialized_candidate_config( normalized_patch = receipt["patch"] knobs = _resolve_knobs(target_step, normalized_patch, workspace) configs, config_paths, before_hashes = _load_configs(workspace, knobs) + before_configs = deepcopy(configs) _apply_patch(configs, knobs, normalized_patch) after_hashes = _write_configs(workspace, configs, config_paths) + snapshots = _write_config_snapshots( + workspace, + receipt["candidate_id"], + before_configs, + configs, + ) updated = _build_receipt( workspace, target_step, @@ -80,6 +91,7 @@ def reapply_materialized_candidate_config( config_paths, before_hashes, after_hashes, + snapshots, ) write_json_atomic(receipt_path, updated) return updated @@ -311,6 +323,41 @@ def _write_configs( return hashes +def _write_config_snapshots( + workspace: Any, + candidate_id: str, + before_configs: dict[str, dict[str, Any]], + after_configs: dict[str, dict[str, Any]], +) -> list[dict[str, str]]: + snapshots: list[dict[str, str]] = [] + for config_key in sorted(after_configs): + before_path = _snapshot_path(workspace, candidate_id, config_key, "before") + after_path = _snapshot_path(workspace, candidate_id, config_key, "after") + write_json_atomic(before_path, before_configs[config_key]) + write_json_atomic(after_path, after_configs[config_key]) + before_sha256 = sha256_path(before_path) + after_sha256 = sha256_path(after_path) + if before_sha256 is None or after_sha256 is None: + raise CandidateMaterializationError("failed to write candidate config snapshots") + snapshots.append( + { + "config_key": config_key, + "before_ref": workspace_relative_ref(workspace.directory, before_path), + "before_sha256": before_sha256, + "after_ref": workspace_relative_ref(workspace.directory, after_path), + "after_sha256": after_sha256, + } + ) + return snapshots + + +def _snapshot_path(workspace: Any, candidate_id: str, config_key: str, state: str) -> Path: + return workspace_analysis_path( + workspace.directory, + f"candidate_config_snapshots.v1/{candidate_id}/{config_key}.{state}.json", + ) + + def _build_receipt( workspace: Any, target_step: str, @@ -320,6 +367,7 @@ def _build_receipt( config_paths: dict[str, Path], before_hashes: dict[str, str], after_hashes: dict[str, str], + snapshots: list[dict[str, str]], ) -> dict[str, Any]: configs = [ { @@ -340,6 +388,7 @@ def _build_receipt( "patch": patch, "patch_sha256": sha256_bytes(canonical_json_bytes(patch)), "configs": configs, + "snapshots": snapshots, } receipt["receipt_sha256"] = _receipt_digest(receipt) return receipt @@ -367,6 +416,8 @@ def validate_materialized_candidate_config(workspace: Any, target_step: str) -> return None _require_candidate_target_backend(workspace, target_step) _verify_materialized_config_hashes(workspace, receipt["configs"]) + if snapshots := receipt.get("snapshots"): + _verify_config_snapshot_hashes(workspace, snapshots) return receipt["candidate_id"] @@ -407,6 +458,9 @@ def _read_receipt(path: Path) -> dict[str, Any]: if receipt.get("receipt_sha256") != _receipt_digest(receipt): raise CandidateMaterializationError("candidate materialization receipt hash is invalid") _validate_config_receipts(receipt.get("configs")) + snapshots = receipt.get("snapshots") + if snapshots is not None: + _validate_snapshot_receipts(snapshots) receipt["candidate_id"] = candidate_id return receipt @@ -435,6 +489,31 @@ def _validate_config_receipts(configs: Any) -> None: raise CandidateMaterializationError("candidate materialization config hash is invalid") +def _validate_snapshot_receipts(snapshots: Any) -> None: + if not isinstance(snapshots, list) or not snapshots: + raise CandidateMaterializationError("candidate config snapshots are invalid") + for entry in snapshots: + if not isinstance(entry, dict) or set(entry) != { + "config_key", + "before_ref", + "before_sha256", + "after_ref", + "after_sha256", + }: + raise CandidateMaterializationError("candidate config snapshot receipt is invalid") + if not isinstance(entry["config_key"], str) or not entry["config_key"]: + raise CandidateMaterializationError("candidate config snapshot key is invalid") + if not all( + isinstance(entry[key], str) and entry[key] for key in ("before_ref", "after_ref") + ): + raise CandidateMaterializationError("candidate config snapshot ref is invalid") + if not all( + isinstance(entry[key], str) and entry[key].startswith("sha256:") + for key in ("before_sha256", "after_sha256") + ): + raise CandidateMaterializationError("candidate config snapshot hash is invalid") + + def _verify_materialized_config_hashes(workspace: Any, configs: list[dict[str, Any]]) -> None: root = Path(workspace.directory).expanduser().resolve() for entry in configs: @@ -449,6 +528,22 @@ def _verify_materialized_config_hashes(workspace: Any, configs: list[dict[str, A raise CandidateMaterializationError("materialized candidate config drift") +def _verify_config_snapshot_hashes(workspace: Any, snapshots: list[dict[str, str]]) -> None: + root = Path(workspace.directory).expanduser().resolve() + for entry in snapshots: + for state in ("before", "after"): + ref = entry[f"{state}_ref"] + path = (root / ref).resolve() + try: + relative = workspace_relative_ref(root, path) + except ValueError as error: + raise CandidateMaterializationError( + "candidate config snapshot ref escapes workspace" + ) from error + if relative != ref or sha256_path(path) != entry[f"{state}_sha256"]: + raise CandidateMaterializationError("candidate config snapshot drift") + + def _receipt_digest(receipt: dict[str, Any]) -> str: payload = {key: value for key, value in receipt.items() if key != "receipt_sha256"} return sha256_bytes(canonical_json_bytes(payload)) diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index 778e67e89..b0f61645f 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -205,6 +205,40 @@ def test_materialize_legalization_overlay_targets_real_dreamplace_config(tmp_pat assert receipt["configs"][0]["ref"] == "config/dreamplace_ecc.json" +def test_materialization_preserves_complete_before_and_after_config_snapshots(tmp_path): + workspace = _workspace(tmp_path) + + receipt = materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + + snapshot = receipt["snapshots"][0] + before = _read_json(tmp_path / snapshot["before_ref"]) + after = _read_json(tmp_path / snapshot["after_ref"]) + + assert snapshot["config_key"] == "dreamplace" + assert before["target_density"] == 0.8 + assert after["target_density"] == 0.7 + assert snapshot["after_sha256"] == _sha256(workspace.config["dreamplace"]) + + +def test_materialized_candidate_rejects_tampered_config_snapshot(tmp_path): + workspace = _workspace(tmp_path) + receipt = materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + (tmp_path / receipt["snapshots"][0]["after_ref"]).write_text("{}\n", encoding="utf-8") + + with pytest.raises(CandidateMaterializationError, match="config snapshot drift"): + validate_materialized_candidate_config(workspace, "place") + + @pytest.mark.parametrize( ("target_step", "patch", "config_key", "path", "reset_value", "expected"), [ From 1ba2aa275975a7c2a79d0c0a588a2580dac20cab Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 22 Aug 2026 14:41:15 +0800 Subject: [PATCH 18/90] fix: isolate candidate rerun workspaces --- agent/test/test_workspace_api.py | 213 +++++++++++++++++++++++++------ agent/workspace_api.py | 146 +++++++++++++++++---- 2 files changed, 300 insertions(+), 59 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 19501390e..b48062b9e 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -1,13 +1,16 @@ +import json import threading from pathlib import Path from types import SimpleNamespace import pytest +from agent.data.candidate_artifacts import sha256_path from agent.requests import CandidateRerunRequest from agent.workspace_api import ( FlowAgentRuntimeApi, _candidate_step_artifact_dirs, + _reject_workspace_symlinks, build_agent_flow_for_workspace, ) from chipcompiler.data import StateEnum @@ -60,40 +63,58 @@ def create_step_workspaces(self): def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( monkeypatch, tmp_path ): + flow_data = { + "steps": [ + {"name": "Floorplan", "tool": "ecc", "state": "Success"}, + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Success"}, + ] + } + flow_path = tmp_path / "home" / "flow.json" + flow_path.parent.mkdir() + flow_path.write_text(json.dumps(flow_data), encoding="utf-8") + parent_flow_bytes = flow_path.read_bytes() + config_path = tmp_path / "config" / "dreamplace.json" + config_path.parent.mkdir() + config_path.write_text('{"target_density": 0.5}\n', encoding="utf-8") workspace = SimpleNamespace( directory=tmp_path, - flow=SimpleNamespace( - data={ - "steps": [ - {"name": "Floorplan", "tool": "ecc", "state": "Success"}, - {"name": "place", "tool": "dreamplace", "state": "Success"}, - {"name": "CTS", "tool": "ecc", "state": "Success"}, - ] - } - ), + flow=SimpleNamespace(data=flow_data, path=flow_path), ) - place_output = tmp_path / "place_dreamplace" / "output" - place_analysis = tmp_path / "place_dreamplace" / "analysis" - cts_output = tmp_path / "CTS_ecc" / "output" - for directory in (place_output, place_analysis, cts_output): + for directory in ( + tmp_path / "place_dreamplace" / "output", + tmp_path / "place_dreamplace" / "analysis", + tmp_path / "CTS_ecc" / "output", + ): directory.mkdir(parents=True) (directory / "stale").write_text("stale", encoding="utf-8") - flow = _Flow( - workspace, - ( - SimpleNamespace(name="Floorplan", tool="ecc", output={}), - SimpleNamespace( - name="place", - tool="dreamplace", - output=EccOutput(dir=place_output), - analysis={"dir": place_analysis}, - ), - SimpleNamespace(name="CTS", tool="ecc", output={"dir": cts_output}), - ), - ) api = FlowAgentRuntimeApi(_EccApi(workspace)) calls = [] - monkeypatch.setattr("agent.workspace_api.build_agent_flow_for_workspace", lambda _ws: flow) + flows = [] + + def build_flow(candidate_workspace): + root = Path(candidate_workspace.directory) + flow = _Flow( + candidate_workspace, + ( + SimpleNamespace(name="Floorplan", tool="ecc", output={}), + SimpleNamespace( + name="place", + tool="dreamplace", + output=EccOutput(dir=root / "place_dreamplace" / "output"), + analysis={"dir": root / "place_dreamplace" / "analysis"}, + ), + SimpleNamespace( + name="CTS", + tool="ecc", + output={"dir": root / "CTS_ecc" / "output"}, + ), + ), + ) + flows.append(flow) + return flow + + monkeypatch.setattr("agent.workspace_api.build_agent_flow_for_workspace", build_flow) monkeypatch.setattr( "agent.workspace_api.bind_candidate_input", lambda _ws, _flow, target, source, candidate: calls.append( @@ -102,8 +123,11 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( ) monkeypatch.setattr( "agent.workspace_api.materialize_candidate_config", - lambda _ws, target, patch, candidate: calls.append( - ("materialize", target, patch, candidate) + lambda candidate_workspace, target, patch, candidate: ( + (Path(candidate_workspace.directory) / "config" / "dreamplace.json").write_text( + '{"target_density": 0.6}\n', encoding="utf-8" + ), + calls.append(("materialize", target, patch, candidate)), ), ) monkeypatch.setattr( @@ -149,7 +173,7 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( ) assert duplicate["operationId"] == result["operationId"] assert duplicate["deduplicated"] is True - _wait_for_terminal(api.ecc_api.operations, result["operationId"]) + terminal = _wait_for_terminal(api.ecc_api.operations, result["operationId"]) assert calls == [ ("bind", "place", "Floorplan", "candidate-1"), ( @@ -162,10 +186,36 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( ("init", "place"), ("init", "CTS"), ] - assert flow.run_calls == [("place", True), ("CTS", True)] - assert not list(place_output.iterdir()) - assert not list(place_analysis.iterdir()) - assert not list(cts_output.iterdir()) + candidate_root = tmp_path / ".agent" / "candidates" / "candidate-1" + candidate_root_ref = ".agent/candidates/candidate-1" + candidate_manifest_ref = f"{candidate_root_ref}/analysis/candidate_workspace.v1.json" + assert flows[0].run_calls == [("place", True), ("CTS", True)] + assert flow_path.read_bytes() == parent_flow_bytes + assert config_path.read_text(encoding="utf-8") == '{"target_density": 0.5}\n' + assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() + assert (tmp_path / "place_dreamplace" / "analysis" / "stale").is_file() + assert (tmp_path / "CTS_ecc" / "output" / "stale").is_file() + assert (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") == ( + '{"target_density": 0.6}\n' + ) + assert not list((candidate_root / "place_dreamplace" / "output").iterdir()) + assert not list((candidate_root / "place_dreamplace" / "analysis").iterdir()) + assert not list((candidate_root / "CTS_ecc" / "output").iterdir()) + candidate_manifest = candidate_root / "analysis" / "candidate_workspace.v1.json" + result = terminal["result"] + assert {key: value for key, value in result.items() if key != "candidateManifestSha256"} == { + "candidateId": "candidate-1", + "candidateManifestRef": candidate_manifest_ref, + "candidateRootRef": candidate_root_ref, + "endStep": "CTS", + "executionScope": "full_flow", + "targetStep": "place", + } + assert candidate_manifest.is_file() + assert result["candidateManifestSha256"] == sha256_path(candidate_manifest) + assert ( + json.loads(candidate_manifest.read_text(encoding="utf-8"))["candidate_id"] == "candidate-1" + ) def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): @@ -192,6 +242,86 @@ def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(t assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] +def test_candidate_rerun_rejects_unsafe_candidate_id_before_starting_an_operation(tmp_path): + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="candidate_id"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="../escape", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + +def test_candidate_rerun_rejects_parent_workspace_symlinks(tmp_path): + target = tmp_path / "outside" + target.mkdir() + (tmp_path / "unsafe-link").symlink_to(target, target_is_directory=True) + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + operation = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + ) + ) + + terminal = _wait_for_terminal(api.ecc_api.operations, operation["operationId"], "failed") + assert "symbolic link" in terminal["error"]["message"] + assert not (tmp_path / ".agent").exists() + + +def test_candidate_snapshot_ignores_prior_candidate_symlinks(tmp_path): + candidate_dir = tmp_path / ".agent" / "candidates" / "old-candidate" + candidate_dir.mkdir(parents=True) + (candidate_dir / "tool-link").symlink_to(tmp_path, target_is_directory=True) + + _reject_workspace_symlinks(tmp_path) + + +def test_candidate_rerun_removes_partial_clone_on_copy_failure(monkeypatch, tmp_path): + (tmp_path / "home").mkdir() + (tmp_path / "home" / "flow.json").write_text('{"steps": []}', encoding="utf-8") + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + def fail_copy(*_args, **_kwargs): + raise OSError("copy failed") + + monkeypatch.setattr("agent.workspace_api.shutil.copytree", fail_copy) + + operation = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + ) + ) + + terminal = _wait_for_terminal(api.ecc_api.operations, operation["operationId"], "failed") + assert "candidate workspace clone failed" in terminal["error"]["message"] + assert not (tmp_path / ".agent" / "candidates" / "candidate-1").exists() + + class _EccApi: def __init__(self, workspace): self.session = SimpleNamespace(workspace=workspace, db_handle=None) @@ -202,6 +332,16 @@ def _get_session(self, workspace_id): assert workspace_id == "workspace-1" return self.session + def _load_workspace(self, directory): + root = Path(directory) + flow_path = root / "home" / "flow.json" + return SimpleNamespace( + directory=root, + flow=SimpleNamespace( + data=json.loads(flow_path.read_text(encoding="utf-8")), path=flow_path + ), + ) + def _with_session_mutation_lock(self, workspace_id, operation): assert workspace_id == "workspace-1" return operation(self.session) @@ -230,6 +370,7 @@ def get_step(self, name, tool): ) def save(self): + self.workspace.flow.path.write_text(json.dumps(self.workspace.flow.data), encoding="utf-8") return True def run_step(self, step, *, rerun, observer=None): @@ -240,12 +381,12 @@ def run_step(self, step, *, rerun, observer=None): return StateEnum.Success -def _wait_for_terminal(operations, operation_id): +def _wait_for_terminal(operations, operation_id, expected_state="succeeded"): deadline = threading.Event() for _ in range(100): status = operations.operation_status(operation_id) if status["state"] in {"succeeded", "failed", "cancelled"}: - assert status["state"] == "succeeded" + assert status["state"] == expected_state return status deadline.wait(0.01) raise AssertionError("candidate operation did not reach a terminal state") diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 6fe71e3de..449b0595a 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -1,4 +1,5 @@ import json +import os import re import shutil from hashlib import sha256 @@ -22,6 +23,7 @@ reapply_candidate_input_binding, validate_candidate_step_contract, ) +from .data.candidate_artifacts import sha256_path, validate_candidate_id, write_json_atomic from .engine import AgentEngineFlow from .requests import ( CandidateBindInputRequest, @@ -111,12 +113,10 @@ def candidate_rerun(self, request: CandidateRerunRequest) -> dict: raise RuntimeApiError("command_failed", str(exc)) from exc def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: - should_capture = self.ecc_api._should_capture_session_db(session) - previous_db = session.db_handle if should_capture else None - if should_capture: - self.ecc_api._release_session_db(session) - previous_db = None - flow = self._build_flow(session) + candidate_workspace, candidate_root_ref, parent_flow_sha256 = _create_candidate_workspace( + self.ecc_api, session.workspace, request.candidate_id + ) + flow = self._build_flow(candidate_workspace) try: steps = _candidate_rerun_steps( flow, @@ -125,37 +125,32 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> request.execution_scope, ) if request.patch: - _materialize_candidate_rerun(session.workspace, flow, request) - _prepare_candidate_rerun(session.workspace, flow, steps) + _materialize_candidate_rerun(candidate_workspace, flow, request) + _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: - _reapply_candidate_input(session.workspace, flow, request.target_step) + _reapply_candidate_input(candidate_workspace, flow, request.target_step) for step in steps: _run_candidate_step(flow, step, observer=observer) return { "candidateId": request.candidate_id, + **_candidate_workspace_receipt( + candidate_workspace, + candidate_root_ref, + request.candidate_id, + parent_flow_sha256, + ), "endStep": request.end_step, "executionScope": request.execution_scope, "targetStep": request.target_step, } finally: - self._finish_flow( - session, - flow, - should_capture=should_capture, - previous_db=previous_db, - ) + self.ecc_api._close_transient_flow_db(flow) - def _build_flow(self, session): - flow = build_agent_flow_for_workspace(session.workspace) + def _build_flow(self, workspace): + flow = build_agent_flow_for_workspace(workspace) return flow - def _finish_flow(self, session, flow, *, should_capture: bool, previous_db) -> None: - if should_capture: - self.ecc_api._capture_flow_db(session, flow, previous_handle=previous_db) - else: - self.ecc_api._close_transient_flow_db(flow) - def _with_workspace_lock(self, workspace_id: str, operation): return self.ecc_api._with_session_mutation_lock(workspace_id, operation) @@ -209,6 +204,10 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: value = getattr(request, name) if not isinstance(value, str) or not value.strip(): raise RuntimeApiError("invalid_request", f"candidate rerun {name} is invalid") + try: + validate_candidate_id(request.candidate_id) + except ValueError as exc: + raise RuntimeApiError("invalid_request", "candidate rerun candidate_id is invalid") from exc if request.execution_scope != "full_flow": raise RuntimeApiError( "invalid_request", "candidate rerun execution scope must be full_flow" @@ -242,6 +241,107 @@ def _notify_candidate_rerun_prepared(observer, steps: list, request: CandidateRe ) +_CANDIDATE_WORKSPACE_SCHEMA = "ecc.workspace.candidate_workspace.v1" +_CANDIDATE_WORKSPACE_MANIFEST = "candidate_workspace.v1.json" + + +def _create_candidate_workspace(ecc_api, workspace, candidate_id: str): + parent_root = _parent_workspace_root(workspace) + _reject_workspace_symlinks(parent_root) + candidate_root = _candidate_workspace_root(parent_root, candidate_id) + parent_flow_sha256 = _required_file_sha256(parent_root / "home" / "flow.json", "flow") + candidate_root.parent.mkdir(parents=True, exist_ok=True) + try: + candidate_root.parent.resolve().relative_to(parent_root) + except ValueError as exc: + raise RuntimeApiError( + "command_failed", "candidate workspace root escaped its parent" + ) from exc + if candidate_root.exists() or candidate_root.is_symlink(): + raise RuntimeApiError("command_failed", "candidate workspace already exists") + try: + shutil.copytree(parent_root, candidate_root, ignore=shutil.ignore_patterns(".agent")) + except OSError as exc: + _remove_failed_candidate_workspace(candidate_root) + raise RuntimeApiError("command_failed", f"candidate workspace clone failed: {exc}") from exc + candidate_workspace = ecc_api._load_workspace(str(candidate_root)) + if Path(candidate_workspace.directory).resolve() != candidate_root: + raise RuntimeApiError("command_failed", "candidate workspace load escaped its root") + return ( + candidate_workspace, + candidate_root.relative_to(parent_root).as_posix(), + parent_flow_sha256, + ) + + +def _parent_workspace_root(workspace) -> Path: + directory = Path(workspace.directory).expanduser() + if directory.is_symlink() or not directory.is_dir(): + raise RuntimeApiError("command_failed", "candidate parent workspace is invalid") + return directory.resolve() + + +def _reject_workspace_symlinks(workspace_root: Path) -> None: + for directory, directories, files in os.walk(workspace_root, followlinks=False): + for name in directories + files: + if (Path(directory) / name).is_symlink(): + raise RuntimeApiError( + "command_failed", "candidate parent workspace has a symbolic link" + ) + if Path(directory) == workspace_root: + directories[:] = [name for name in directories if name != ".agent"] + + +def _candidate_workspace_root(parent_root: Path, candidate_id: str) -> Path: + try: + validate_candidate_id(candidate_id) + except ValueError as exc: + raise RuntimeApiError("invalid_request", "candidate rerun candidate_id is invalid") from exc + root = parent_root / ".agent" / "candidates" / candidate_id + if root.exists() or root.is_symlink(): + raise RuntimeApiError("command_failed", "candidate workspace already exists") + return root + + +def _required_file_sha256(path: Path, label: str) -> str: + if path.is_symlink() or not path.is_file() or (digest := sha256_path(path)) is None: + raise RuntimeApiError("command_failed", f"candidate {label} is missing or unsafe") + return digest + + +def _remove_failed_candidate_workspace(candidate_root: Path) -> None: + if candidate_root.is_dir() and not candidate_root.is_symlink(): + shutil.rmtree(candidate_root) + + +def _candidate_workspace_receipt( + workspace, candidate_root_ref: str, candidate_id: str, parent_flow_sha256: str +) -> dict: + candidate_root = Path(workspace.directory).resolve() + manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST + if manifest_path.parent.is_symlink(): + raise RuntimeApiError("command_failed", "candidate manifest path is unsafe") + candidate_flow_sha256 = _required_file_sha256(candidate_root / "home" / "flow.json", "flow") + manifest = { + "schema": _CANDIDATE_WORKSPACE_SCHEMA, + "schema_version": 1, + "candidate_id": candidate_id, + "candidate_root_ref": candidate_root_ref, + "parent_flow_sha256": parent_flow_sha256, + "candidate_flow_sha256": candidate_flow_sha256, + } + try: + write_json_atomic(manifest_path, manifest) + except OSError as exc: + raise RuntimeApiError("command_failed", f"candidate manifest write failed: {exc}") from exc + manifest_sha256 = _required_file_sha256(manifest_path, "manifest") + return { + "candidateRootRef": candidate_root_ref, + "candidateManifestRef": f"{candidate_root_ref}/analysis/{_CANDIDATE_WORKSPACE_MANIFEST}", + "candidateManifestSha256": manifest_sha256, + } + + def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: source_step = _candidate_source_step(flow, request.target_step) bind_candidate_input( From 4b6690538ebf974c683edf81d83bf55f92cff217 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Mon, 24 Aug 2026 14:50:39 +0800 Subject: [PATCH 19/90] fix: inherit incumbent candidate workspace --- agent/requests.py | 2 + agent/test/test_requests.py | 2 + agent/test/test_workspace_api.py | 72 ++++++++++++++++++++++++++++---- agent/workspace_api.py | 63 +++++++++++++++++++++++----- 4 files changed, 120 insertions(+), 19 deletions(-) diff --git a/agent/requests.py b/agent/requests.py index 6f02671f3..b92c3bc88 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -34,6 +34,7 @@ class CandidateRerunRequest: patch: list[dict[str, Any]] execution_scope: str idempotency_key: str + parent_candidate_root_ref: str | None = None _FIELD_ALIASES = { @@ -44,6 +45,7 @@ class CandidateRerunRequest: "candidateId": "candidate_id", "executionScope": "execution_scope", "idempotencyKey": "idempotency_key", + "parentCandidateRootRef": "parent_candidate_root_ref", } diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index d530e1559..3bd317200 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -38,6 +38,7 @@ def test_agent_request_normalizes_camel_case_fields(): "patch": [], "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", + "parentCandidateRootRef": ".agent/candidates/candidate-0", }, ) @@ -49,6 +50,7 @@ def test_agent_request_normalizes_camel_case_fields(): patch=[], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + parent_candidate_root_ref=".agent/candidates/candidate-0", ) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index b48062b9e..773651b83 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -121,15 +121,15 @@ def build_flow(candidate_workspace): ("bind", target, source, candidate) ), ) - monkeypatch.setattr( - "agent.workspace_api.materialize_candidate_config", - lambda candidate_workspace, target, patch, candidate: ( - (Path(candidate_workspace.directory) / "config" / "dreamplace.json").write_text( - '{"target_density": 0.6}\n', encoding="utf-8" - ), - calls.append(("materialize", target, patch, candidate)), - ), - ) + + def materialize(candidate_workspace, target, patch, candidate): + path = Path(candidate_workspace.directory) / "config" / "dreamplace.json" + config = json.loads(path.read_text(encoding="utf-8")) + config[patch[0]["knob_id"].removeprefix("place.")] = patch[0]["value"] + path.write_text(json.dumps(config, sort_keys=True) + "\n", encoding="utf-8") + calls.append(("materialize", target, patch, candidate)) + + monkeypatch.setattr("agent.workspace_api.materialize_candidate_config", materialize) monkeypatch.setattr( "agent.workspace_api.validate_candidate_step_contract", lambda _ws, _target: "candidate-1", @@ -217,6 +217,37 @@ def build_flow(candidate_workspace): json.loads(candidate_manifest.read_text(encoding="utf-8"))["candidate_id"] == "candidate-1" ) + second = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-2", + patch=[{"knob_id": "place.routability_opt", "value": True}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-2", + parent_candidate_root_ref=candidate_root_ref, + ) + ) + _wait_for_terminal(api.ecc_api.operations, second["operationId"]) + second_config = json.loads( + ( + tmp_path / ".agent" / "candidates" / "candidate-2" / "config" / "dreamplace.json" + ).read_text(encoding="utf-8") + ) + assert second_config == {"routability_opt": True, "target_density": 0.6} + second_manifest = json.loads( + ( + tmp_path + / ".agent" + / "candidates" + / "candidate-2" + / "analysis" + / "candidate_workspace.v1.json" + ).read_text(encoding="utf-8") + ) + assert second_manifest["parent_candidate_root_ref"] == candidate_root_ref + def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): workspace = SimpleNamespace(directory=tmp_path) @@ -262,6 +293,29 @@ def test_candidate_rerun_rejects_unsafe_candidate_id_before_starting_an_operatio assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] +def test_candidate_rerun_rejects_unsafe_parent_candidate_ref_before_starting_an_operation( + tmp_path, +): + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="parent_candidate_root_ref"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + parent_candidate_root_ref="../outside", + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + def test_candidate_rerun_rejects_parent_workspace_symlinks(tmp_path): target = tmp_path / "outside" target.mkdir() diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 449b0595a..13dbd13db 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -114,7 +114,10 @@ def candidate_rerun(self, request: CandidateRerunRequest) -> dict: def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: candidate_workspace, candidate_root_ref, parent_flow_sha256 = _create_candidate_workspace( - self.ecc_api, session.workspace, request.candidate_id + self.ecc_api, + session.workspace, + request.candidate_id, + request.parent_candidate_root_ref, ) flow = self._build_flow(candidate_workspace) try: @@ -139,6 +142,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> candidate_root_ref, request.candidate_id, parent_flow_sha256, + request.parent_candidate_root_ref, ), "endStep": request.end_step, "executionScope": request.execution_scope, @@ -229,6 +233,8 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: request.idempotency_key ): raise RuntimeApiError("invalid_request", "candidate rerun idempotency key is invalid") + if request.parent_candidate_root_ref is not None: + _validate_parent_candidate_root_ref(request.parent_candidate_root_ref) def _notify_candidate_rerun_prepared(observer, steps: list, request: CandidateRerunRequest) -> None: @@ -245,14 +251,17 @@ def _notify_candidate_rerun_prepared(observer, steps: list, request: CandidateRe _CANDIDATE_WORKSPACE_MANIFEST = "candidate_workspace.v1.json" -def _create_candidate_workspace(ecc_api, workspace, candidate_id: str): - parent_root = _parent_workspace_root(workspace) - _reject_workspace_symlinks(parent_root) - candidate_root = _candidate_workspace_root(parent_root, candidate_id) - parent_flow_sha256 = _required_file_sha256(parent_root / "home" / "flow.json", "flow") +def _create_candidate_workspace( + ecc_api, workspace, candidate_id: str, parent_candidate_root_ref: str | None = None +): + workspace_root = _parent_workspace_root(workspace) + source_root = _candidate_parent_root(workspace_root, parent_candidate_root_ref) + _reject_workspace_symlinks(source_root) + candidate_root = _candidate_workspace_root(workspace_root, candidate_id) + parent_flow_sha256 = _required_file_sha256(source_root / "home" / "flow.json", "flow") candidate_root.parent.mkdir(parents=True, exist_ok=True) try: - candidate_root.parent.resolve().relative_to(parent_root) + candidate_root.parent.resolve().relative_to(workspace_root) except ValueError as exc: raise RuntimeApiError( "command_failed", "candidate workspace root escaped its parent" @@ -260,7 +269,7 @@ def _create_candidate_workspace(ecc_api, workspace, candidate_id: str): if candidate_root.exists() or candidate_root.is_symlink(): raise RuntimeApiError("command_failed", "candidate workspace already exists") try: - shutil.copytree(parent_root, candidate_root, ignore=shutil.ignore_patterns(".agent")) + shutil.copytree(source_root, candidate_root, ignore=shutil.ignore_patterns(".agent")) except OSError as exc: _remove_failed_candidate_workspace(candidate_root) raise RuntimeApiError("command_failed", f"candidate workspace clone failed: {exc}") from exc @@ -269,7 +278,7 @@ def _create_candidate_workspace(ecc_api, workspace, candidate_id: str): raise RuntimeApiError("command_failed", "candidate workspace load escaped its root") return ( candidate_workspace, - candidate_root.relative_to(parent_root).as_posix(), + candidate_root.relative_to(workspace_root).as_posix(), parent_flow_sha256, ) @@ -281,6 +290,35 @@ def _parent_workspace_root(workspace) -> Path: return directory.resolve() +def _validate_parent_candidate_root_ref(value: object) -> str: + if not isinstance(value, str): + raise RuntimeApiError( + "invalid_request", "candidate rerun parent_candidate_root_ref is invalid" + ) + parts = Path(value).parts + if len(parts) != 3 or parts[:2] != (".agent", "candidates"): + raise RuntimeApiError( + "invalid_request", "candidate rerun parent_candidate_root_ref is invalid" + ) + try: + validate_candidate_id(parts[2]) + except ValueError as exc: + raise RuntimeApiError( + "invalid_request", "candidate rerun parent_candidate_root_ref is invalid" + ) from exc + return value + + +def _candidate_parent_root(workspace_root: Path, candidate_root_ref: str | None) -> Path: + if candidate_root_ref is None: + return workspace_root + source = workspace_root / _validate_parent_candidate_root_ref(candidate_root_ref) + resolved = source.resolve() + if source.is_symlink() or not source.is_dir() or resolved != source.absolute(): + raise RuntimeApiError("command_failed", "candidate parent workspace is invalid") + return resolved + + def _reject_workspace_symlinks(workspace_root: Path) -> None: for directory, directories, files in os.walk(workspace_root, followlinks=False): for name in directories + files: @@ -315,7 +353,11 @@ def _remove_failed_candidate_workspace(candidate_root: Path) -> None: def _candidate_workspace_receipt( - workspace, candidate_root_ref: str, candidate_id: str, parent_flow_sha256: str + workspace, + candidate_root_ref: str, + candidate_id: str, + parent_flow_sha256: str, + parent_candidate_root_ref: str | None, ) -> dict: candidate_root = Path(workspace.directory).resolve() manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST @@ -327,6 +369,7 @@ def _candidate_workspace_receipt( "schema_version": 1, "candidate_id": candidate_id, "candidate_root_ref": candidate_root_ref, + "parent_candidate_root_ref": parent_candidate_root_ref, "parent_flow_sha256": parent_flow_sha256, "candidate_flow_sha256": candidate_flow_sha256, } From c058cd51093f2f17db1229a5e2fadbef94681180 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 26 Aug 2026 12:51:39 +0800 Subject: [PATCH 20/90] fix: support floorplan candidate reruns --- agent/data/candidate_input_binding.py | 3 ++- .../test/data/test_candidate_input_binding.py | 20 ++++++++++++++++++ agent/test/test_workspace_api.py | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/agent/data/candidate_input_binding.py b/agent/data/candidate_input_binding.py index 1c0018b2c..02b505658 100644 --- a/agent/data/candidate_input_binding.py +++ b/agent/data/candidate_input_binding.py @@ -20,6 +20,7 @@ CANONICAL_INPUT_EDGES = frozenset( { ("Floorplan", "initial"), + ("Floorplan", "Synthesis"), ("place", "Floorplan"), ("CTS", "place"), ("legalization", "CTS"), @@ -132,7 +133,7 @@ def _path_or_none(value: Any) -> Path | None: def _validate_source_inputs(source_step: str, inputs: dict[str, Path | None]) -> None: def_hash = sha256_path(inputs["def"]) if inputs["def"] else None verilog_hash = sha256_path(inputs["verilog"]) if inputs["verilog"] else None - if source_step != "initial" and def_hash is None: + if source_step not in {"initial", "Synthesis"} and def_hash is None: raise CandidateInputBindingError(f"candidate source {source_step} has no DEF checkpoint") if def_hash is None and verilog_hash is None: raise CandidateInputBindingError(f"candidate source {source_step} has no design checkpoint") diff --git a/agent/test/data/test_candidate_input_binding.py b/agent/test/data/test_candidate_input_binding.py index 967f9081a..c547ab88f 100644 --- a/agent/test/data/test_candidate_input_binding.py +++ b/agent/test/data/test_candidate_input_binding.py @@ -111,6 +111,7 @@ def test_bind_candidate_input_reads_typed_ecc_output_paths(tmp_path): "target_step,source_step", [ ("Floorplan", "initial"), + ("Floorplan", "Synthesis"), ("place", "Floorplan"), ("CTS", "place"), ("legalization", "CTS"), @@ -145,6 +146,25 @@ def test_canonical_candidate_input_edges_are_declared(tmp_path, target_step, sou assert receipt["source"] == {"step": source_step} +def test_floorplan_accepts_a_verilog_only_synthesis_checkpoint(tmp_path): + floorplan = _step(tmp_path, "Floorplan") + synthesis = _step(tmp_path, "Synthesis") + synthesis.output["def"].unlink() + synthesis.output["def"] = None + workspace = SimpleNamespace(directory=str(tmp_path), design=SimpleNamespace()) + + receipt = bind_candidate_input( + workspace, + _Flow(floorplan, synthesis), + "Floorplan", + "Synthesis", + candidate_id="floorplan-from-synthesis", + ) + + assert receipt["inputs"]["def"] is None + assert receipt["inputs"]["verilog"]["sha256"] == _sha256(synthesis.output["verilog"]) + + def test_noncanonical_candidate_edge_is_rejected(tmp_path): cts = _step(tmp_path, "CTS") route = _step(tmp_path, "route") diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 773651b83..4fdcc7877 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -9,6 +9,7 @@ from agent.requests import CandidateRerunRequest from agent.workspace_api import ( FlowAgentRuntimeApi, + _candidate_rerun_steps, _candidate_step_artifact_dirs, _reject_workspace_symlinks, build_agent_flow_for_workspace, @@ -30,6 +31,26 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) +@pytest.mark.parametrize( + "target_step,expected_first", + [ + ("Floorplan", "Floorplan"), + ("fixFanout", "fixFanout"), + ("place", "place"), + ], +) +def test_candidate_rerun_slice_starts_at_the_modified_stage( + target_step: str, expected_first: str +) -> None: + names = ("Synthesis", "Floorplan", "fixFanout", "place", "CTS", "Harden") + flow = SimpleNamespace(workspace_steps=tuple(SimpleNamespace(name=name) for name in names)) + + steps = _candidate_rerun_steps(flow, target_step, "Harden", "full_flow") + + assert steps[0].name == expected_first + assert steps[-1].name == "Harden" + + def test_agent_flow_defaults_to_harden_flow(monkeypatch): class RecordingFlow: def __init__(self, workspace): From a36711572153e56d1e3fef5b73f44fa4422ecee9 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:07:34 +0800 Subject: [PATCH 21/90] feat: add native parameter application receipt producer --- agent/data/parameter_application_receipt.py | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 agent/data/parameter_application_receipt.py diff --git a/agent/data/parameter_application_receipt.py b/agent/data/parameter_application_receipt.py new file mode 100644 index 000000000..171151915 --- /dev/null +++ b/agent/data/parameter_application_receipt.py @@ -0,0 +1,71 @@ +"""ECC-side producer for the hash-bound parameter application receipt. + +This module deliberately has no dependency on ``ecos_agent``. Tool adapters +pass structured consumer facts; this producer only assembles and persists the +frozen JSON envelope. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +def _sha256(value: Any) -> str: + data = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_parameter_application_receipt( + *, + receipt_id: str, + tool: Mapping[str, Any], + context: Mapping[str, Any], + requested: Mapping[str, Any], + materialization: Mapping[str, Any], + runtime_report: Mapping[str, Any], + destination: Path | None = None, +) -> dict[str, Any]: + """Aggregate native runtime facts and optionally atomically write the receipt.""" + if not receipt_id or not requested.get("knob_id"): + raise ValueError("receipt identity is required") + activation = runtime_report.get("activation") + if not isinstance(activation, Mapping): + raise ValueError("native activation facts are required") + if activation.get("status") == "used" and not activation.get("consumers"): + raise ValueError("used activation requires consumer evidence") + normalized_tool = dict(tool) + normalized_tool.setdefault("source_sha256", None) + normalized_materialization = dict(materialization) + normalized_materialization.setdefault("parent_ref", None) + payload: dict[str, Any] = { + "schema_version": "tool.parameter_application_receipt.v1", + "receipt_id": receipt_id, + "tool": normalized_tool, + "context": dict(context), + "requested": dict(requested), + "materialization": normalized_materialization, + "effective_initial": runtime_report.get( + "effective_initial", {"value": None, "unit": requested.get("unit", "")} + ), + "transitions": list(runtime_report.get("transitions", [])), + "application_status": runtime_report.get("application_status", "unknown"), + "activation": dict(activation), + "effective_final": runtime_report.get( + "effective_final", {"value": None, "unit": requested.get("unit", "")} + ), + } + payload["evidence_sha256"] = _sha256(payload) + if destination is not None: + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(destination.name + ".tmp") + temporary.write_text( + json.dumps(payload, sort_keys=True, separators=(",", ":")), encoding="utf-8" + ) + os.replace(temporary, destination) + return payload From 26cc8e1064d73bee1b9712d984938dd1a8b67fbf Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:12:06 +0800 Subject: [PATCH 22/90] feat: return native parameter receipt from candidate rerun --- agent/workspace_api.py | 62 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 13dbd13db..f425a0b96 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -24,6 +24,7 @@ validate_candidate_step_contract, ) from .data.candidate_artifacts import sha256_path, validate_candidate_id, write_json_atomic +from .data.parameter_application_receipt import build_parameter_application_receipt from .engine import AgentEngineFlow from .requests import ( CandidateBindInputRequest, @@ -135,7 +136,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> _reapply_candidate_input(candidate_workspace, flow, request.target_step) for step in steps: _run_candidate_step(flow, step, observer=observer) - return { + result = { "candidateId": request.candidate_id, **_candidate_workspace_receipt( candidate_workspace, @@ -148,6 +149,16 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> "executionScope": request.execution_scope, "targetStep": request.target_step, } + materialization_path = ( + Path(candidate_workspace.directory) + / "analysis" + / "candidate_materialization.v1.json" + ) + if materialization_path.is_file(): + result["parameterApplicationReceipt"] = _candidate_parameter_receipt( + candidate_workspace, request, candidate_root_ref, materialization_path + ) + return result finally: self.ecc_api._close_transient_flow_db(flow) @@ -385,6 +396,55 @@ def _candidate_workspace_receipt( } +def _candidate_parameter_receipt( + workspace, request, candidate_root_ref: str, materialization_path: Path +) -> dict: + materialization = json.loads(materialization_path.read_text(encoding="utf-8")) + configs = materialization.get("configs") or [{}] + config = configs[0] + patch = request.patch[0] + knob_id = patch["knob_id"] + unit = ( + "boolean" + if knob_id.endswith("routability_opt") + else "site" + if knob_id.endswith("cell_padding_x") + else "count" + if knob_id.endswith("fanout") + else "ratio" + ) + h = sha256(materialization_path.read_bytes()).hexdigest() + digest = f"sha256:{h}" + return build_parameter_application_receipt( + receipt_id=f"parameter-receipt-{request.candidate_id}", + tool={"name": "ECC", "revision": "runtime"}, + context={ + "run_id": request.candidate_id, + "stage": request.target_step, + "lattice_version": "ecos.optimization_lattice.v1", + }, + requested={"knob_id": knob_id, "value": patch["value"], "unit": unit}, + materialization={ + "receipt_ref": "analysis/candidate_materialization.v1.json", + "receipt_sha256": materialization.get("receipt_sha256", digest), + "registry_sha256": materialization.get("registry_sha256", digest), + "patch_sha256": materialization.get("patch_sha256", digest), + "candidate_ref": candidate_root_ref, + "workspace_ref": candidate_root_ref, + "config_before_sha256": config.get("before_sha256", digest), + "config_after_sha256": config.get("after_sha256", digest), + "written_value": patch["value"], + "unit": unit, + }, + runtime_report={ + "application_status": "unknown", + "activation": {"status": "unknown", "consumers": []}, + "effective_initial": {"value": None, "unit": unit}, + "effective_final": {"value": None, "unit": unit}, + }, + ) + + def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: source_step = _candidate_source_step(flow, request.target_step) bind_candidate_input( From 60a943856a9d1f2999f1504498ca8785a7ba445e Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:21:16 +0800 Subject: [PATCH 23/90] feat: record native placement parameter runtime report --- agent/workspace_api.py | 19 ++++-- chipcompiler/tools/ecc_dreamplace/module.py | 62 +++++++++++++++++++ .../test_parameter_runtime_report.py | 34 ++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 test/tools/ecc_dreamplace/test_parameter_runtime_report.py diff --git a/agent/workspace_api.py b/agent/workspace_api.py index f425a0b96..fb25ec770 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -415,6 +415,18 @@ def _candidate_parameter_receipt( ) h = sha256(materialization_path.read_bytes()).hexdigest() digest = f"sha256:{h}" + runtime_report_path = ( + Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" + ) + try: + runtime_report = json.loads(runtime_report_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + runtime_report = { + "application_status": "unknown", + "activation": {"status": "unknown", "consumers": []}, + "effective_initial": {"value": None, "unit": unit}, + "effective_final": {"value": None, "unit": unit}, + } return build_parameter_application_receipt( receipt_id=f"parameter-receipt-{request.candidate_id}", tool={"name": "ECC", "revision": "runtime"}, @@ -436,12 +448,7 @@ def _candidate_parameter_receipt( "written_value": patch["value"], "unit": unit, }, - runtime_report={ - "application_status": "unknown", - "activation": {"status": "unknown", "consumers": []}, - "effective_initial": {"value": None, "unit": unit}, - "effective_final": {"value": None, "unit": unit}, - }, + runtime_report=runtime_report, ) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index d5433333e..eb6cbc33d 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -1,5 +1,6 @@ #!/usr/bin/env python +import hashlib import json import logging import os @@ -113,6 +114,7 @@ def _run(self, *, legalize_only: bool) -> bool: with self._configure_root_logging(legalize_only=legalize_only): params = self._build_params(Params, legalize_only=legalize_only) + _write_parameter_runtime_report(self.workspace, params) engine = PlacementEngine(params) engine.setup_rawdb(ecc_module=self.ecc_module) @@ -135,3 +137,63 @@ def run_legalization(self) -> bool: __all__ = ["DreamplaceModule"] + + +def _write_parameter_runtime_report(workspace: Workspace, params) -> None: + """Record the selected candidate knob at the native DreamPlace boundary.""" + report_path = Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" + materialization_path = ( + Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" + ) + if not materialization_path.is_file(): + return + try: + materialization = json.loads(materialization_path.read_text(encoding="utf-8")) + patch = materialization["patch"][0] + except (OSError, ValueError, KeyError, IndexError, TypeError): + return + knob_id = patch.get("knob_id") + key_by_knob = { + "place.target_density": ("target_density", "dreamplace.density_objective"), + "place.target_overflow": ("stop_overflow", "dreamplace.overflow_predicate"), + "place.cell_padding_x": ("cell_padding_x", "dreamplace.cell_size_expansion"), + "place.routability_opt": ("routability_opt_flag", "dreamplace.routability_branch"), + "place.density_weight": ("density_weight", "dreamplace.density_preconditioner"), + } + if knob_id not in key_by_knob: + return + key, consumer_id = key_by_knob[knob_id] + value = getattr(params, key, None) + status = "used" if value is not None else "unknown" + if knob_id == "place.routability_opt" and value in (False, 0): + status = "not_activated" + evidence = { + "consumer_id": consumer_id, + "outcome": "entered" if status == "used" else "evaluated", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + } + evidence["evidence_sha256"] = ( + "sha256:" + + hashlib.sha256( + json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + report = { + "application_status": "applied" if value is not None else "unknown", + "effective_initial": { + "value": value, + "unit": "dbu" if knob_id.endswith("cell_padding_x") else "ratio", + }, + "effective_final": { + "value": value, + "unit": "dbu" if knob_id.endswith("cell_padding_x") else "ratio", + }, + "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, + "transitions": [], + } + report_path.parent.mkdir(parents=True, exist_ok=True) + temporary = report_path.with_suffix(report_path.suffix + ".tmp") + temporary.write_text( + json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" + ) + os.replace(temporary, report_path) diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py new file mode 100644 index 000000000..96f59cb8d --- /dev/null +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from chipcompiler.tools.ecc_dreamplace.module import _write_parameter_runtime_report + + +def test_runtime_report_records_native_density_consumer(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + encoding="utf-8", + ) + params = SimpleNamespace(target_density=0.85) + _write_parameter_runtime_report(SimpleNamespace(directory=tmp_path), params) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "used" + assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" + + +def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": False}]}), + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), SimpleNamespace(routability_opt_flag=False) + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "not_activated" From 53349e8267567d38acd1507b567614f3902bb415 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:24:43 +0800 Subject: [PATCH 24/90] fix: bind native receipts to reviewed tool identities --- agent/workspace_api.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index fb25ec770..7893ba3f9 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -427,9 +427,16 @@ def _candidate_parameter_receipt( "effective_initial": {"value": None, "unit": unit}, "effective_final": {"value": None, "unit": unit}, } + tool_name = ( + "ECC-Floorplan" + if knob_id.startswith("floorplan.") + else "ECC-fixFanout" + if knob_id == "synth.max_fanout" + else "DREAMPlace" + ) return build_parameter_application_receipt( receipt_id=f"parameter-receipt-{request.candidate_id}", - tool={"name": "ECC", "revision": "runtime"}, + tool={"name": tool_name, "revision": "bound"}, context={ "run_id": request.candidate_id, "stage": request.target_step, From 03ecac43f1c73f09af8c8f816787fab727155587 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:29:45 +0800 Subject: [PATCH 25/90] feat: record floorplan parameter runtime evidence --- chipcompiler/tools/ecc/runner.py | 70 ++++++++++++++++++ .../ecc/test_floorplan_runtime_report.py | 74 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 test/tools/ecc/test_floorplan_runtime_report.py diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 65f929dab..2f93378e6 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +import hashlib +import json import os import shutil from pathlib import Path @@ -734,6 +736,9 @@ def run_floorplan( ecc_module.init_fp(config=workspace.config.get(StepEnum.FLOORPLAN.value, "")) sub_flow.update_step(step_name=EccSubFlowEnum.init_floorplan.value, state=StateEnum.Success) + _write_floorplan_parameter_runtime_report( + workspace, workspace.config.get(StepEnum.FLOORPLAN.value, "") + ) ecc_module.run_fp() sub_flow.update_step(step_name=EccSubFlowEnum.create_tracks.value, state=StateEnum.Success) @@ -759,6 +764,71 @@ def run_floorplan( return reslut +def _write_floorplan_parameter_runtime_report( + workspace: Workspace, config_path: str | Path +) -> None: + """Record the candidate knob consumed by iFP's native die builder.""" + workspace_dir = getattr(workspace, "directory", None) + if workspace_dir is None: + return + materialization_path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" + if not materialization_path.is_file(): + return + try: + materialization = json.loads(materialization_path.read_text(encoding="utf-8")) + patch = next( + item + for item in materialization["patch"] + if item.get("knob_id") in {"floorplan.core_util", "floorplan.aspect_ratio"} + ) + floorplan = json.loads(Path(config_path).read_text(encoding="utf-8")) + die_builder = floorplan["die_builder"] + die_util = die_builder["die_util"] + except (OSError, ValueError, KeyError, TypeError, StopIteration): + return + + knob_id = patch["knob_id"] + field, consumer_id = { + "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), + "floorplan.aspect_ratio": ("aspect_ratio", "ifp.die_builder.die_aspect_ratio"), + }[knob_id] + value = die_util.get(field) + requested = patch.get("value") + mode = die_builder.get("mode") + matches_request = value == requested + status = "used" if mode == "die_util" and value is not None and matches_request else "unknown" + if mode != "die_util" and value is not None and matches_request: + status = "not_activated" + evidence = { + "consumer_id": consumer_id, + "outcome": "entered" if status == "used" else "evaluated", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + } + evidence["evidence_sha256"] = ( + "sha256:" + + hashlib.sha256( + json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + report = { + "application_status": "applied" if matches_request else "unknown", + "effective_initial": {"value": value, "unit": "ratio"}, + "effective_final": {"value": value, "unit": "ratio"}, + "activation": { + "status": status, + "consumers": [evidence] if status in {"used", "not_activated"} else [], + }, + "transitions": [], + } + report_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" + report_path.parent.mkdir(parents=True, exist_ok=True) + temporary = report_path.with_name(report_path.name + ".tmp") + temporary.write_text( + json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" + ) + os.replace(temporary, report_path) + + def run_harden( workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | None = None ) -> bool: diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py new file mode 100644 index 000000000..3e5dfb47c --- /dev/null +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from chipcompiler.tools.ecc.runner import _write_floorplan_parameter_runtime_report + + +def _write_candidate(tmp_path: Path, knob_id: str, value: float) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": knob_id, "value": value}]}), encoding="utf-8" + ) + + +def _write_config(tmp_path: Path, *, mode: str, field: str, value: float) -> Path: + config_path = tmp_path / "config" / "floorplan_ecc.json" + config_path.parent.mkdir() + config_path.write_text( + json.dumps({"die_builder": {"mode": mode, "die_util": {field: value}}}), + encoding="utf-8", + ) + return config_path + + +def test_runtime_report_records_native_core_utilization_consumer(tmp_path): + _write_candidate(tmp_path, "floorplan.core_util", 0.8) + config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) + + _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "used" + assert report["activation"]["consumers"][0]["consumer_id"] == ( + "ifp.die_builder.die_utilization" + ) + assert report["effective_final"] == {"value": 0.8, "unit": "ratio"} + + +def test_runtime_report_records_native_aspect_ratio_consumer(tmp_path): + _write_candidate(tmp_path, "floorplan.aspect_ratio", 1.25) + config_path = _write_config(tmp_path, mode="die_util", field="aspect_ratio", value=1.25) + + _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "used" + assert report["activation"]["consumers"][0]["consumer_id"] == ( + "ifp.die_builder.die_aspect_ratio" + ) + + +def test_runtime_report_marks_die_size_mode_not_activated(tmp_path): + _write_candidate(tmp_path, "floorplan.core_util", 0.8) + config_path = _write_config(tmp_path, mode="die_size", field="utilization", value=0.8) + + _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "not_activated" + + +def test_runtime_report_does_not_claim_mismatched_native_value(tmp_path): + _write_candidate(tmp_path, "floorplan.core_util", 0.8) + config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.7) + + _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["application_status"] == "unknown" + assert report["activation"]["status"] == "unknown" + assert report["activation"]["consumers"] == [] From c6e09e98fdcee4099ba2731d545350e795bd5253 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 15:34:44 +0800 Subject: [PATCH 26/90] feat: persist candidate parameter evidence artifacts --- .../test/test_parameter_receipt_artifacts.py | 40 +++++++++++++++++++ agent/workspace_api.py | 36 ++++++++++++----- 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 agent/test/test_parameter_receipt_artifacts.py diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py new file mode 100644 index 000000000..d339e9847 --- /dev/null +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from agent.data.candidate_artifacts import sha256_path +from agent.workspace_api import _candidate_parameter_receipt + + +def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + materialization = analysis / "candidate_materialization.v1.json" + materialization.write_text( + json.dumps( + { + "patch": [{"knob_id": "place.target_density", "value": 0.85}], + "configs": [{}], + } + ), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-1", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + ) + + receipt = _candidate_parameter_receipt( + SimpleNamespace(directory=tmp_path), + request, + ".agent/candidates/candidate-1", + materialization, + ) + + receipt_path = analysis / "parameter_application_receipt.v1.json" + assert receipt_path.is_file() + assert json.loads(receipt_path.read_text(encoding="utf-8")) == receipt + assert sha256_path(receipt_path) is not None diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 7893ba3f9..90a43855a 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -136,6 +136,16 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> _reapply_candidate_input(candidate_workspace, flow, request.target_step) for step in steps: _run_candidate_step(flow, step, observer=observer) + parameter_receipt = None + materialization_path = ( + Path(candidate_workspace.directory) + / "analysis" + / "candidate_materialization.v1.json" + ) + if materialization_path.is_file(): + parameter_receipt = _candidate_parameter_receipt( + candidate_workspace, request, candidate_root_ref, materialization_path + ) result = { "candidateId": request.candidate_id, **_candidate_workspace_receipt( @@ -149,15 +159,8 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> "executionScope": request.execution_scope, "targetStep": request.target_step, } - materialization_path = ( - Path(candidate_workspace.directory) - / "analysis" - / "candidate_materialization.v1.json" - ) - if materialization_path.is_file(): - result["parameterApplicationReceipt"] = _candidate_parameter_receipt( - candidate_workspace, request, candidate_root_ref, materialization_path - ) + if parameter_receipt is not None: + result["parameterApplicationReceipt"] = parameter_receipt return result finally: self.ecc_api._close_transient_flow_db(flow) @@ -384,6 +387,19 @@ def _candidate_workspace_receipt( "parent_flow_sha256": parent_flow_sha256, "candidate_flow_sha256": candidate_flow_sha256, } + artifacts = {} + for key, relative in ( + ("parameter_runtime_report", "analysis/parameter_runtime_report.v1.json"), + ("parameter_application_receipt", "analysis/parameter_application_receipt.v1.json"), + ): + artifact = candidate_root / relative + if artifact.is_file() and not artifact.is_symlink(): + artifacts[key] = { + "ref": relative, + "sha256": _required_file_sha256(artifact, key), + } + if artifacts: + manifest["artifacts"] = artifacts try: write_json_atomic(manifest_path, manifest) except OSError as exc: @@ -434,6 +450,7 @@ def _candidate_parameter_receipt( if knob_id == "synth.max_fanout" else "DREAMPlace" ) + receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" return build_parameter_application_receipt( receipt_id=f"parameter-receipt-{request.candidate_id}", tool={"name": tool_name, "revision": "bound"}, @@ -456,6 +473,7 @@ def _candidate_parameter_receipt( "unit": unit, }, runtime_report=runtime_report, + destination=receipt_path, ) From 938c11f6e9504887c501af45f9b7482a6b41a38c Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 16:11:03 +0800 Subject: [PATCH 27/90] feat: bind candidate manifest execution context --- agent/workspace_api.py | 116 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 7 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 90a43855a..554ce04f3 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -34,6 +34,11 @@ ) +def _stable_hash(value) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return f"sha256:{sha256(payload).hexdigest()}" + + def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): import chipcompiler.rtl2gds as rtl2gds_api @@ -144,7 +149,11 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> ) if materialization_path.is_file(): parameter_receipt = _candidate_parameter_receipt( - candidate_workspace, request, candidate_root_ref, materialization_path + candidate_workspace, + request, + candidate_root_ref, + materialization_path, + parent_flow_sha256, ) result = { "candidateId": request.candidate_id, @@ -154,6 +163,9 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> request.candidate_id, parent_flow_sha256, request.parent_candidate_root_ref, + request.target_step, + request.end_step, + request.execution_scope, ), "endStep": request.end_step, "executionScope": request.execution_scope, @@ -372,6 +384,9 @@ def _candidate_workspace_receipt( candidate_id: str, parent_flow_sha256: str, parent_candidate_root_ref: str | None, + target_step: str, + end_step: str, + execution_scope: str, ) -> dict: candidate_root = Path(workspace.directory).resolve() manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST @@ -386,6 +401,9 @@ def _candidate_workspace_receipt( "parent_candidate_root_ref": parent_candidate_root_ref, "parent_flow_sha256": parent_flow_sha256, "candidate_flow_sha256": candidate_flow_sha256, + "target_step": target_step, + "end_step": end_step, + "execution_scope": execution_scope, } artifacts = {} for key, relative in ( @@ -413,7 +431,11 @@ def _candidate_workspace_receipt( def _candidate_parameter_receipt( - workspace, request, candidate_root_ref: str, materialization_path: Path + workspace, + request, + candidate_root_ref: str, + materialization_path: Path, + parent_flow_sha256: str | None = None, ) -> dict: materialization = json.loads(materialization_path.read_text(encoding="utf-8")) configs = materialization.get("configs") or [{}] @@ -451,14 +473,19 @@ def _candidate_parameter_receipt( else "DREAMPlace" ) receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" - return build_parameter_application_receipt( - receipt_id=f"parameter-receipt-{request.candidate_id}", - tool={"name": tool_name, "revision": "bound"}, - context={ + context = ( + _parameter_receipt_context(workspace, request, parent_flow_sha256) + if parent_flow_sha256 is not None + else { "run_id": request.candidate_id, "stage": request.target_step, "lattice_version": "ecos.optimization_lattice.v1", - }, + } + ) + return build_parameter_application_receipt( + receipt_id=f"parameter-receipt-{request.candidate_id}", + tool={"name": tool_name, "revision": "bound"}, + context=context, requested={"knob_id": knob_id, "value": patch["value"], "unit": unit}, materialization={ "receipt_ref": "analysis/candidate_materialization.v1.json", @@ -477,6 +504,81 @@ def _candidate_parameter_receipt( ) +def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> dict[str, object]: + root = Path(workspace.directory) + origin = root / "origin" + rtl_files = sorted(path for path in (origin / "rtl").glob("*") if path.is_file()) + sdc_files = sorted(origin.glob("*.sdc")) + if not rtl_files or not sdc_files: + raise RuntimeApiError("command_failed", "candidate input fingerprints are unavailable") + try: + parameters = json.loads((root / "home" / "parameters.json").read_text(encoding="utf-8")) + pdk_root = Path(parameters["PDK Root"]) + tech_lef = pdk_root / "prtech" / "techLEF" / "N551P6M_ecos.lef" + pdk_sha256 = f"sha256:{sha256(tech_lef.read_bytes()).hexdigest()}" + lef_text = tech_lef.read_text(encoding="utf-8") + units_match = re.search(r"DATABASE\s+MICRONS\s+(\d+)", lef_text, re.IGNORECASE) + site_match = re.search( + r"SITE\s+(?:core7|CoreSite)\b(?P.*?)END\s+(?:core7|CoreSite)", + lef_text, + re.IGNORECASE | re.DOTALL, + ) + size_match = re.search( + r"SIZE\s+([0-9]+(?:\.[0-9]+)?)\s+BY", + site_match.group("body") if site_match else "", + re.IGNORECASE, + ) + if not units_match or not size_match: + raise ValueError("site width is unavailable") + site_width_dbu = round(float(units_match.group(1)) * float(size_match.group(1))) + except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeApiError("command_failed", "candidate PDK fingerprint is unavailable") from exc + rtl_hashes = [f"sha256:{sha256(path.read_bytes()).hexdigest()}" for path in rtl_files] + sdc_hashes = [f"sha256:{sha256(path.read_bytes()).hexdigest()}" for path in sdc_files] + filelist = origin / "filelist.f" + if not filelist.is_file(): + raise RuntimeApiError("command_failed", "candidate filelist fingerprint is unavailable") + filelist_sha256 = f"sha256:{sha256(filelist.read_bytes()).hexdigest()}" + design_sha256 = _stable_hash( + { + "rtl_sha256": rtl_hashes[0], + "filelist_sha256": filelist_sha256, + "sdc_sha256": sdc_hashes[0], + } + ) + knob_name = str(request.patch[0].get("knob_id")) + unit = ( + "boolean" + if knob_name.endswith("routability_opt") + else "site" + if knob_name.endswith("cell_padding_x") + else "count" + if knob_name.endswith("fanout") + else "ratio" + ) + context = { + "run_id": request.candidate_id, + "design_sha256": design_sha256, + "stage": request.target_step, + "backend": "ecc", + "lattice_version": "ecos.optimization_lattice.v1", + "rtl_sha256": ( + rtl_hashes[0] if len(rtl_hashes) == 1 else _stable_hash({"files": rtl_hashes}) + ), + "filelist_sha256": filelist_sha256, + "sdc_sha256": ( + sdc_hashes[0] if len(sdc_hashes) == 1 else _stable_hash({"files": sdc_hashes}) + ), + "pdk_sha256": pdk_sha256, + "parent_lineage_sha256": parent_flow_sha256, + "seed": 0, + "site_width_dbu": site_width_dbu, + "tool_revision": "bound", + "unit": unit, + } + return context + + def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: source_step = _candidate_source_step(flow, request.target_step) bind_candidate_input( From 0ede3888cf3a6587bbcceaba9edd5e1effaca73a Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 16:20:58 +0800 Subject: [PATCH 28/90] feat: record floorplan geometry evidence --- chipcompiler/tools/ecc/runner.py | 86 +++++++++++++++++-- .../ecc/test_floorplan_runtime_report.py | 38 ++++++++ 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 2f93378e6..139df7505 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -2,7 +2,9 @@ import hashlib import json import os +import re import shutil +from math import isfinite from pathlib import Path from chipcompiler.data import ( @@ -756,6 +758,12 @@ def run_floorplan( feature_step=False, report_timing=False, ) + _write_floorplan_parameter_runtime_report( + workspace, + workspace.config.get(StepEnum.FLOORPLAN.value, ""), + feature_path=step.feature.db, + report_path=step.report.db, + ) sub_flow.update_step(step_name=EccSubFlowEnum.save_data.value, state=StateEnum.Success) @@ -765,7 +773,11 @@ def run_floorplan( def _write_floorplan_parameter_runtime_report( - workspace: Workspace, config_path: str | Path + workspace: Workspace, + config_path: str | Path, + *, + feature_path: str | Path | None = None, + report_path: str | Path | None = None, ) -> None: """Record the candidate knob consumed by iFP's native die builder.""" workspace_dir = getattr(workspace, "directory", None) @@ -820,13 +832,77 @@ def _write_floorplan_parameter_runtime_report( }, "transitions": [], } - report_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" - report_path.parent.mkdir(parents=True, exist_ok=True) - temporary = report_path.with_name(report_path.name + ".tmp") + observation = _floorplan_geometry_observation(feature_path, report_path) + if observation is not None: + report["consumer_observation"] = observation + if feature_path is not None and not _floorplan_observation_complete(observation): + report["application_status"] = "unknown" + report["activation"] = {"status": "unknown", "consumers": []} + output_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") temporary.write_text( json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" ) - os.replace(temporary, report_path) + os.replace(temporary, output_path) + + +def _floorplan_geometry_observation( + feature_path: str | Path | None, report_path: str | Path | None +) -> dict | None: + if not feature_path or not Path(feature_path).is_file(): + return None + try: + feature = json.loads(Path(feature_path).read_text(encoding="utf-8")) + layout = feature["Design Layout"] + width = layout["core_bounding_width"] + height = layout["core_bounding_height"] + area = layout.get("core_area") + except (OSError, ValueError, KeyError, TypeError): + return None + numeric = all(isinstance(item, (int, float)) and isfinite(item) for item in (width, height)) + if not numeric or width <= 0 or height <= 0: + return None + ratio = width / height + rows, sites = _floorplan_report_counts(report_path) + return { + "core_geometry": { + "width": {"value": width, "unit": "um"}, + "height": {"value": height, "unit": "um"}, + "area": {"value": area, "unit": "um^2"}, + "aspect_ratio": {"value": ratio, "unit": "ratio"}, + }, + "rows": {"count": rows, "observed": rows is not None}, + "sites": {"count": sites, "observed": sites is not None}, + } + + +def _floorplan_observation_complete(observation: dict | None) -> bool: + if not observation: + return False + geometry = observation.get("core_geometry", {}) + return all( + geometry.get(name, {}).get("value") is not None + for name in ("width", "height", "area", "aspect_ratio") + ) and ( + observation.get("rows", {}).get("observed") is True + and observation.get("sites", {}).get("observed") is True + ) + + +def _floorplan_report_counts(report_path: str | Path | None) -> tuple[int | None, int | None]: + if not report_path or not Path(report_path).is_file(): + return None, None + try: + text = Path(report_path).read_text(encoding="utf-8", errors="replace") + except OSError: + return None, None + values = {} + for name in ("Site", "Row"): + match = re.search(rf"Number\s*-\s*{name}[^0-9]*(\d+)", text) + if match: + values[name] = int(match.group(1)) + return values.get("Row"), values.get("Site") def run_harden( diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py index 3e5dfb47c..c374e5842 100644 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -72,3 +72,41 @@ def test_runtime_report_does_not_claim_mismatched_native_value(tmp_path): assert report["application_status"] == "unknown" assert report["activation"]["status"] == "unknown" assert report["activation"]["consumers"] == [] + + +def test_runtime_report_records_native_core_geometry_rows_and_sites(tmp_path): + _write_candidate(tmp_path, "floorplan.core_util", 0.8) + config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) + feature_path = tmp_path / "feature.json" + feature_path.write_text( + json.dumps( + { + "Design Layout": { + "core_area": 800.0, + "core_bounding_width": 40.0, + "core_bounding_height": 20.0, + } + } + ), + encoding="utf-8", + ) + report_path = tmp_path / "report.rpt" + report_path.write_text("Number - Site | 120\nNumber - Row | 30\n", encoding="utf-8") + + _write_floorplan_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + config_path, + feature_path=feature_path, + report_path=report_path, + ) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + observation = report["consumer_observation"] + assert observation["core_geometry"] == { + "width": {"value": 40.0, "unit": "um"}, + "height": {"value": 20.0, "unit": "um"}, + "area": {"value": 800.0, "unit": "um^2"}, + "aspect_ratio": {"value": 2.0, "unit": "ratio"}, + } + assert observation["rows"] == {"count": 30, "observed": True} + assert observation["sites"] == {"count": 120, "observed": True} From 66f1b9f4d721e43559c0a2bbe77a2b755ce1e18e Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 16:34:41 +0800 Subject: [PATCH 29/90] fix: gate DreamPlace parameter activation on engine success --- chipcompiler/tools/ecc_dreamplace/module.py | 9 ++++++--- .../ecc_dreamplace/test_parameter_runtime_report.py | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index eb6cbc33d..f08dc7a95 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -114,17 +114,18 @@ def _run(self, *, legalize_only: bool) -> bool: with self._configure_root_logging(legalize_only=legalize_only): params = self._build_params(Params, legalize_only=legalize_only) - _write_parameter_runtime_report(self.workspace, params) engine = PlacementEngine(params) engine.setup_rawdb(ecc_module=self.ecc_module) ppa = engine.run() if ppa.get("hpwl") == float("inf"): + _write_parameter_runtime_report(self.workspace, params, engine_succeeded=False) LOGGER = logging.getLogger(__name__) LOGGER.error("dreamplace failed for %s", self.step.name) return False + _write_parameter_runtime_report(self.workspace, params, engine_succeeded=True) return True def run_placement(self) -> bool: @@ -139,7 +140,9 @@ def run_legalization(self) -> bool: __all__ = ["DreamplaceModule"] -def _write_parameter_runtime_report(workspace: Workspace, params) -> None: +def _write_parameter_runtime_report( + workspace: Workspace, params, *, engine_succeeded: bool = False +) -> None: """Record the selected candidate knob at the native DreamPlace boundary.""" report_path = Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" materialization_path = ( @@ -164,7 +167,7 @@ def _write_parameter_runtime_report(workspace: Workspace, params) -> None: return key, consumer_id = key_by_knob[knob_id] value = getattr(params, key, None) - status = "used" if value is not None else "unknown" + status = "used" if value is not None and engine_succeeded else "unknown" if knob_id == "place.routability_opt" and value in (False, 0): status = "not_activated" evidence = { diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 96f59cb8d..ce9ecdd74 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -14,7 +14,9 @@ def test_runtime_report_records_native_density_consumer(tmp_path): encoding="utf-8", ) params = SimpleNamespace(target_density=0.85) - _write_parameter_runtime_report(SimpleNamespace(directory=tmp_path), params) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), params, engine_succeeded=True + ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "used" assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" From 09b900be4740de48d630fe0fd3b0ca5d7742b364 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 16:38:15 +0800 Subject: [PATCH 30/90] test: cover DreamPlace activation failure state --- .../test_parameter_runtime_report.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index ce9ecdd74..87dd6dc00 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -34,3 +34,17 @@ def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "not_activated" + + +def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), SimpleNamespace(target_density=0.85) + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"] == {"status": "unknown", "consumers": []} From 20adff6cee31f72f5c66e0c8b58a5753bc19cde3 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 17:34:23 +0800 Subject: [PATCH 31/90] fix: require native runtime activation evidence --- chipcompiler/tools/ecc_dreamplace/module.py | 27 ++++++++++++++-- .../test_parameter_runtime_report.py | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index f08dc7a95..594771a13 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -4,6 +4,7 @@ import json import logging import os +import re import sys from contextlib import contextmanager from pathlib import Path @@ -168,8 +169,15 @@ def _write_parameter_runtime_report( key, consumer_id = key_by_knob[knob_id] value = getattr(params, key, None) status = "used" if value is not None and engine_succeeded else "unknown" - if knob_id == "place.routability_opt" and value in (False, 0): - status = "not_activated" + branch_round_count = None + if knob_id == "place.routability_opt": + branch_round_count = _routability_branch_round_count(workspace) + if value in (False, 0): + status = "not_activated" + elif not engine_succeeded: + status = "unknown" + else: + status = "used" if branch_round_count else "not_activated" evidence = { "consumer_id": consumer_id, "outcome": "entered" if status == "used" else "evaluated", @@ -194,9 +202,24 @@ def _write_parameter_runtime_report( "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, "transitions": [], } + if knob_id == "place.routability_opt": + report["consumer_observation"] = { + "branch_round_count": branch_round_count, + "evidence_complete": isinstance(branch_round_count, int), + } report_path.parent.mkdir(parents=True, exist_ok=True) temporary = report_path.with_suffix(report_path.suffix + ".tmp") temporary.write_text( json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" ) os.replace(temporary, report_path) + + +def _routability_branch_round_count(workspace: Workspace) -> int | None: + """Count native routability rounds emitted by the placement engine.""" + log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" + try: + text = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + return len(re.findall(r"routability optimization round \d+:", text)) diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 87dd6dc00..ce5a20f43 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -36,6 +36,38 @@ def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): assert report["activation"]["status"] == "not_activated" +def test_runtime_report_requires_a_native_routability_round(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": True}]}), + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(routability_opt_flag=True), + engine_succeeded=True, + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "not_activated" + assert report["consumer_observation"]["evidence_complete"] is False + + log_dir = tmp_path / "place_dreamplace" / "log" + log_dir.mkdir(parents=True) + (log_dir / "place.log").write_text( + "routability optimization round 0: adjust area flags = (1, 1, 0)\n", + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(routability_opt_flag=True), + engine_succeeded=True, + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "used" + assert report["consumer_observation"]["branch_round_count"] == 1 + + def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() From 632d6b454036ccc79d2fa884d75dccd08232e3ec Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 17:47:03 +0800 Subject: [PATCH 32/90] fix: preserve surface value for padding receipts --- .../test/test_parameter_receipt_artifacts.py | 32 +++++++++++++++++++ agent/workspace_api.py | 8 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index d339e9847..bfc8a4c2e 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -38,3 +38,35 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No assert receipt_path.is_file() assert json.loads(receipt_path.read_text(encoding="utf-8")) == receipt assert sha256_path(receipt_path) is not None + + +def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monkeypatch) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + materialization = analysis / "candidate_materialization.v1.json" + materialization.write_text( + json.dumps( + { + "patch": [{"knob_id": "place.cell_padding_x", "value": 200}], + "configs": [{}], + } + ), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-padding", + target_step="place", + patch=[{"knob_id": "place.cell_padding_x", "value": 200}], + ) + monkeypatch.setattr( + "agent.workspace_api._parameter_receipt_context", + lambda *_args: {"site_width_dbu": 200}, + ) + receipt = _candidate_parameter_receipt( + SimpleNamespace(directory=tmp_path), + request, + ".agent/candidates/candidate-padding", + materialization, + parent_flow_sha256="sha256:" + "0" * 64, + ) + assert receipt["requested"] == {"knob_id": "place.cell_padding_x", "value": 1, "unit": "site"} diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 554ce04f3..b3bddbe27 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -482,11 +482,17 @@ def _candidate_parameter_receipt( "lattice_version": "ecos.optimization_lattice.v1", } ) + requested_value = patch["value"] + if knob_id == "place.cell_padding_x": + site_width = context.get("site_width_dbu") + if type(site_width) is not int or site_width <= 0 or requested_value % site_width: + raise RuntimeApiError("command_failed", "cell padding surface unit is unavailable") + requested_value //= site_width return build_parameter_application_receipt( receipt_id=f"parameter-receipt-{request.candidate_id}", tool={"name": tool_name, "revision": "bound"}, context=context, - requested={"knob_id": knob_id, "value": patch["value"], "unit": unit}, + requested={"knob_id": knob_id, "value": requested_value, "unit": unit}, materialization={ "receipt_ref": "analysis/candidate_materialization.v1.json", "receipt_sha256": materialization.get("receipt_sha256", digest), From 61d759147e9cd17bdb63b843bbfc8e90b8912c1d Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 17:54:51 +0800 Subject: [PATCH 33/90] fix: align fanout receipt units --- agent/workspace_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index b3bddbe27..b52ca20d2 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -447,7 +447,7 @@ def _candidate_parameter_receipt( if knob_id.endswith("routability_opt") else "site" if knob_id.endswith("cell_padding_x") - else "count" + else "fanout" if knob_id.endswith("fanout") else "ratio" ) @@ -558,7 +558,7 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d if knob_name.endswith("routability_opt") else "site" if knob_name.endswith("cell_padding_x") - else "count" + else "fanout" if knob_name.endswith("fanout") else "ratio" ) From cd01b67415fa9a70b49aeb07cf68f8d7b1f94dbd Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 18:00:20 +0800 Subject: [PATCH 34/90] fix: align runtime parameter units --- agent/workspace_api.py | 32 +++++++++------------ chipcompiler/tools/ecc_dreamplace/module.py | 12 ++++++-- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index b52ca20d2..033b457cc 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -39,6 +39,18 @@ def _stable_hash(value) -> str: return f"sha256:{sha256(payload).hexdigest()}" +def _parameter_unit(knob_id: str) -> str: + if knob_id.endswith("routability_opt"): + return "boolean" + if knob_id.endswith("cell_padding_x"): + return "site" + if knob_id.endswith("fanout"): + return "fanout" + if knob_id.endswith("density_weight"): + return "objective_weight" + return "ratio" + + def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): import chipcompiler.rtl2gds as rtl2gds_api @@ -442,15 +454,7 @@ def _candidate_parameter_receipt( config = configs[0] patch = request.patch[0] knob_id = patch["knob_id"] - unit = ( - "boolean" - if knob_id.endswith("routability_opt") - else "site" - if knob_id.endswith("cell_padding_x") - else "fanout" - if knob_id.endswith("fanout") - else "ratio" - ) + unit = _parameter_unit(knob_id) h = sha256(materialization_path.read_bytes()).hexdigest() digest = f"sha256:{h}" runtime_report_path = ( @@ -553,15 +557,7 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d } ) knob_name = str(request.patch[0].get("knob_id")) - unit = ( - "boolean" - if knob_name.endswith("routability_opt") - else "site" - if knob_name.endswith("cell_padding_x") - else "fanout" - if knob_name.endswith("fanout") - else "ratio" - ) + unit = _parameter_unit(knob_name) context = { "run_id": request.candidate_id, "design_sha256": design_sha256, diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 594771a13..5b157110f 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -141,6 +141,14 @@ def run_legalization(self) -> bool: __all__ = ["DreamplaceModule"] +def _runtime_unit(knob_id: str) -> str: + if knob_id.endswith("cell_padding_x"): + return "dbu" + if knob_id.endswith("density_weight"): + return "objective_weight" + return "ratio" + + def _write_parameter_runtime_report( workspace: Workspace, params, *, engine_succeeded: bool = False ) -> None: @@ -193,11 +201,11 @@ def _write_parameter_runtime_report( "application_status": "applied" if value is not None else "unknown", "effective_initial": { "value": value, - "unit": "dbu" if knob_id.endswith("cell_padding_x") else "ratio", + "unit": _runtime_unit(knob_id), }, "effective_final": { "value": value, - "unit": "dbu" if knob_id.endswith("cell_padding_x") else "ratio", + "unit": _runtime_unit(knob_id), }, "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, "transitions": [], From b5928e0fffb1f98ea6999e6fdf20e961f685dea5 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 18:03:19 +0800 Subject: [PATCH 35/90] test: cover density weight runtime unit --- .../test_parameter_runtime_report.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index ce5a20f43..b7cc9a71e 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -22,6 +22,23 @@ def test_runtime_report_records_native_density_consumer(tmp_path): assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" +def test_runtime_report_uses_objective_weight_unit(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.density_weight", "value": 0.001}]}), + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(density_weight=0.001), + engine_succeeded=True, + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["effective_initial"]["unit"] == "objective_weight" + assert report["effective_final"]["unit"] == "objective_weight" + + def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() From 441ec60b3a679b9afe149ab0c79d9008cc5455b7 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 18:15:13 +0800 Subject: [PATCH 36/90] fix: reject no-op candidate materialization --- agent/data/candidate_materialization.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 0d9a9319e..5b588c98f 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -487,6 +487,10 @@ def _validate_config_receipts(configs: Any) -> None: for key in ("before_sha256", "after_sha256") ): raise CandidateMaterializationError("candidate materialization config hash is invalid") + if entry["before_sha256"] == entry["after_sha256"]: + raise CandidateMaterializationError( + "candidate materialization patch did not change config" + ) def _validate_snapshot_receipts(snapshots: Any) -> None: @@ -512,6 +516,8 @@ def _validate_snapshot_receipts(snapshots: Any) -> None: for key in ("before_sha256", "after_sha256") ): raise CandidateMaterializationError("candidate config snapshot hash is invalid") + if entry["before_sha256"] == entry["after_sha256"]: + raise CandidateMaterializationError("candidate config snapshot did not change config") def _verify_materialized_config_hashes(workspace: Any, configs: list[dict[str, Any]]) -> None: From eb6437235068a2f4be17d1d53934c3ff7b63ee36 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 18:28:27 +0800 Subject: [PATCH 37/90] fix: bind native candidate state and parameters --- agent/workspace_api.py | 55 ++++++++++++++++++++- chipcompiler/tools/ecc_dreamplace/module.py | 21 +++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 033b457cc..45f506046 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -131,7 +131,12 @@ def candidate_rerun(self, request: CandidateRerunRequest) -> dict: raise RuntimeApiError("command_failed", str(exc)) from exc def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: - candidate_workspace, candidate_root_ref, parent_flow_sha256 = _create_candidate_workspace( + ( + candidate_workspace, + candidate_root_ref, + parent_flow_sha256, + parent_state_sha256, + ) = _create_candidate_workspace( self.ecc_api, session.workspace, request.candidate_id, @@ -178,6 +183,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> request.target_step, request.end_step, request.execution_scope, + parent_state_sha256, ), "endStep": request.end_step, "executionScope": request.execution_scope, @@ -297,6 +303,7 @@ def _create_candidate_workspace( _reject_workspace_symlinks(source_root) candidate_root = _candidate_workspace_root(workspace_root, candidate_id) parent_flow_sha256 = _required_file_sha256(source_root / "home" / "flow.json", "flow") + parent_state_sha256 = _workspace_state_sha256(source_root) candidate_root.parent.mkdir(parents=True, exist_ok=True) try: candidate_root.parent.resolve().relative_to(workspace_root) @@ -318,9 +325,28 @@ def _create_candidate_workspace( candidate_workspace, candidate_root.relative_to(workspace_root).as_posix(), parent_flow_sha256, + parent_state_sha256, ) +def _workspace_state_sha256(root: Path) -> str: + relative_files = ( + "home/flow.json", + "home/parameters.json", + "config/floorplan_ecc.json", + "config/fixfanout_ecc.json", + "config/dreamplace_ecc.json", + ) + hashes = { + relative: _required_file_sha256(root / relative, relative) + for relative in relative_files + if (root / relative).is_file() and not (root / relative).is_symlink() + } + if not hashes: + raise RuntimeApiError("command_failed", "candidate parent state is unavailable") + return _stable_hash(hashes) + + def _parent_workspace_root(workspace) -> Path: directory = Path(workspace.directory).expanduser() if directory.is_symlink() or not directory.is_dir(): @@ -399,6 +425,7 @@ def _candidate_workspace_receipt( target_step: str, end_step: str, execution_scope: str, + parent_state_sha256: str, ) -> dict: candidate_root = Path(workspace.directory).resolve() manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST @@ -412,6 +439,7 @@ def _candidate_workspace_receipt( "candidate_root_ref": candidate_root_ref, "parent_candidate_root_ref": parent_candidate_root_ref, "parent_flow_sha256": parent_flow_sha256, + "parent_state_sha256": parent_state_sha256, "candidate_flow_sha256": candidate_flow_sha256, "target_step": target_step, "end_step": end_step, @@ -419,6 +447,8 @@ def _candidate_workspace_receipt( } artifacts = {} for key, relative in ( + ("candidate_materialization", "analysis/candidate_materialization.v1.json"), + ("candidate_input_binding", "analysis/candidate_input_binding.v1.json"), ("parameter_runtime_report", "analysis/parameter_runtime_report.v1.json"), ("parameter_application_receipt", "analysis/parameter_application_receipt.v1.json"), ): @@ -430,6 +460,29 @@ def _candidate_workspace_receipt( } if artifacts: manifest["artifacts"] = artifacts + replay_path = candidate_root / "analysis" / "candidate_execution_receipt.v1.json" + replay = { + "schema": "ecc.candidate_execution_receipt.v1", + "candidate_id": candidate_id, + "candidate_root_ref": candidate_root_ref, + "parent_candidate_root_ref": parent_candidate_root_ref, + "parent_flow_sha256": parent_flow_sha256, + "parent_state_sha256": parent_state_sha256, + "target_step": target_step, + "end_step": end_step, + "execution_scope": execution_scope, + "candidate_manifest_sha256": None, + } + try: + write_json_atomic(replay_path, replay) + except OSError as exc: + raise RuntimeApiError( + "command_failed", f"candidate replay receipt write failed: {exc}" + ) from exc + artifacts["candidate_execution_receipt"] = { + "ref": "analysis/candidate_execution_receipt.v1.json", + "sha256": _required_file_sha256(replay_path, "candidate replay receipt"), + } try: write_json_atomic(manifest_path, manifest) except OSError as exc: diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 5b157110f..72026e726 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -1,5 +1,6 @@ #!/usr/bin/env python +import ast import hashlib import json import logging @@ -142,6 +143,8 @@ def run_legalization(self) -> bool: def _runtime_unit(knob_id: str) -> str: + if knob_id.endswith("routability_opt"): + return "boolean" if knob_id.endswith("cell_padding_x"): return "dbu" if knob_id.endswith("density_weight"): @@ -175,7 +178,7 @@ def _write_parameter_runtime_report( if knob_id not in key_by_knob: return key, consumer_id = key_by_knob[knob_id] - value = getattr(params, key, None) + value = _native_parameter_value(workspace, key, getattr(params, key, None)) status = "used" if value is not None and engine_succeeded else "unknown" branch_round_count = None if knob_id == "place.routability_opt": @@ -223,6 +226,22 @@ def _write_parameter_runtime_report( os.replace(temporary, report_path) +def _native_parameter_value(workspace: Workspace, key: str, fallback): + """Read the parameter dictionary emitted by the native placement runner.""" + log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" + try: + for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): + marker = "parameters = " + if marker not in line: + continue + payload = ast.literal_eval(line.split(marker, 1)[1].strip()) + if isinstance(payload, dict) and key in payload: + return payload[key] + except (OSError, SyntaxError, ValueError): + pass + return fallback + + def _routability_branch_round_count(workspace: Workspace) -> int | None: """Count native routability rounds emitted by the placement engine.""" log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" From cad82fbd4be109a0aa757b0a2f87da0b5cc1b661 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 18:37:02 +0800 Subject: [PATCH 38/90] fix: preserve materialized candidate receipts --- agent/data/candidate_materialization.py | 6 ++++++ agent/test/data/test_candidate_materialization.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 5b588c98f..3a3804ed0 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -70,6 +70,12 @@ def reapply_materialized_candidate_config( receipt = _read_receipt(receipt_path) if receipt["target"]["step"] != target_step: return None + current_configs = receipt.get("configs", []) + if all( + sha256_path(_config_path(workspace, entry["config_key"])) == entry["after_sha256"] + for entry in current_configs + ): + return receipt normalized_patch = receipt["patch"] knobs = _resolve_knobs(target_step, normalized_patch, workspace) configs, config_paths, before_hashes = _load_configs(workspace, knobs) diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index b0f61645f..cec760b83 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -225,6 +225,21 @@ def test_materialization_preserves_complete_before_and_after_config_snapshots(tm assert snapshot["after_sha256"] == _sha256(workspace.config["dreamplace"]) +def test_reapply_keeps_original_receipt_when_config_is_already_materialized(tmp_path): + workspace = _workspace(tmp_path) + original = materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + + reapplied = reapply_materialized_candidate_config(workspace, "place") + + assert reapplied == original + assert reapplied["configs"][0]["before_sha256"] != reapplied["configs"][0]["after_sha256"] + + def test_materialized_candidate_rejects_tampered_config_snapshot(tmp_path): workspace = _workspace(tmp_path) receipt = materialize_candidate_config( From 5e1ef39518f366bfbc77db63d82b163e9c17168b Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 19:23:58 +0800 Subject: [PATCH 39/90] fix: preserve candidate materialization snapshots --- agent/data/candidate_materialization.py | 22 +++++++++++++++---- .../data/test_candidate_materialization.py | 17 ++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 3a3804ed0..89e197359 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -1,6 +1,7 @@ """Controlled, replayable config overlays for isolated ECC candidate workspaces.""" import math +import shutil from copy import deepcopy from pathlib import Path from typing import Any @@ -71,10 +72,23 @@ def reapply_materialized_candidate_config( if receipt["target"]["step"] != target_step: return None current_configs = receipt.get("configs", []) - if all( - sha256_path(_config_path(workspace, entry["config_key"])) == entry["after_sha256"] - for entry in current_configs - ): + snapshots = receipt.get("snapshots", []) + if current_configs and snapshots: + _verify_config_snapshot_hashes(workspace, snapshots) + snapshot_by_key = { + item["config_key"]: item + for item in snapshots + if isinstance(item, dict) and isinstance(item.get("config_key"), str) + } + for entry in current_configs: + snapshot = snapshot_by_key.get(entry.get("config_key")) + if snapshot is None: + raise CandidateMaterializationError("candidate config snapshot is incomplete") + after_ref = snapshot.get("after_ref") + if not isinstance(after_ref, str): + raise CandidateMaterializationError("candidate config snapshot ref is invalid") + after_path = Path(workspace.directory) / after_ref + shutil.copyfile(after_path, _config_path(workspace, entry["config_key"])) return receipt normalized_patch = receipt["patch"] knobs = _resolve_knobs(target_step, normalized_patch, workspace) diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index cec760b83..6d02d51db 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -225,6 +225,23 @@ def test_materialization_preserves_complete_before_and_after_config_snapshots(tm assert snapshot["after_sha256"] == _sha256(workspace.config["dreamplace"]) +def test_reapply_keeps_receipt_when_tool_rewrites_equivalent_json(tmp_path): + workspace = _workspace(tmp_path) + original = materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.6}], + candidate_id="equivalent-json-candidate", + ) + config_path = workspace.config["dreamplace"] + config_path.write_text(json.dumps(_read_json(config_path), indent=4) + "\n", encoding="utf-8") + + reapplied = reapply_materialized_candidate_config(workspace, "place") + + assert reapplied["receipt_sha256"] == original["receipt_sha256"] + assert reapplied["snapshots"] == original["snapshots"] + + def test_reapply_keeps_original_receipt_when_config_is_already_materialized(tmp_path): workspace = _workspace(tmp_path) original = materialize_candidate_config( From b8292276c1d19f2817aeae6ef21fe10fb5c4585c Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 19:31:48 +0800 Subject: [PATCH 40/90] fix: materialize candidate before step workspaces --- agent/workspace_api.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 45f506046..9a284d49c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -142,7 +142,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> request.candidate_id, request.parent_candidate_root_ref, ) - flow = self._build_flow(candidate_workspace) + flow = self._build_flow(candidate_workspace, create_step_workspaces=False) try: steps = _candidate_rerun_steps( flow, @@ -152,6 +152,9 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> ) if request.patch: _materialize_candidate_rerun(candidate_workspace, flow, request) + create_step_workspaces = getattr(flow, "create_step_workspaces", None) + if callable(create_step_workspaces): + create_step_workspaces() _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: @@ -195,8 +198,15 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> finally: self.ecc_api._close_transient_flow_db(flow) - def _build_flow(self, workspace): - flow = build_agent_flow_for_workspace(workspace) + def _build_flow(self, workspace, *, create_step_workspaces: bool = True): + try: + flow = build_agent_flow_for_workspace( + workspace, create_step_workspaces=create_step_workspaces + ) + except TypeError as exc: + if "create_step_workspaces" not in str(exc): + raise + flow = build_agent_flow_for_workspace(workspace) return flow def _with_workspace_lock(self, workspace_id: str, operation): From 4e0d1ded4c000fbcc491bcb103f6953872893e2c Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 21:14:16 +0800 Subject: [PATCH 41/90] fix: require native parameter runtime evidence --- chipcompiler/tools/ecc_dreamplace/module.py | 19 +---------------- .../test_parameter_runtime_report.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 72026e726..9fdda87b4 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -import ast import hashlib import json import logging @@ -178,7 +177,7 @@ def _write_parameter_runtime_report( if knob_id not in key_by_knob: return key, consumer_id = key_by_knob[knob_id] - value = _native_parameter_value(workspace, key, getattr(params, key, None)) + value = getattr(params, key, None) status = "used" if value is not None and engine_succeeded else "unknown" branch_round_count = None if knob_id == "place.routability_opt": @@ -226,22 +225,6 @@ def _write_parameter_runtime_report( os.replace(temporary, report_path) -def _native_parameter_value(workspace: Workspace, key: str, fallback): - """Read the parameter dictionary emitted by the native placement runner.""" - log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" - try: - for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): - marker = "parameters = " - if marker not in line: - continue - payload = ast.literal_eval(line.split(marker, 1)[1].strip()) - if isinstance(payload, dict) and key in payload: - return payload[key] - except (OSError, SyntaxError, ValueError): - pass - return fallback - - def _routability_branch_round_count(workspace: Workspace) -> int | None: """Count native routability rounds emitted by the placement engine.""" log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index b7cc9a71e..ec14587ea 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -22,6 +22,27 @@ def test_runtime_report_records_native_density_consumer(tmp_path): assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" +def test_runtime_report_does_not_parse_logged_parameter_values(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + encoding="utf-8", + ) + log_dir = tmp_path / "place_dreamplace" / "log" + log_dir.mkdir(parents=True) + (log_dir / "place.log").write_text("parameters = {'target_density': 0.2}\n", encoding="utf-8") + + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(target_density=0.85), + engine_succeeded=True, + ) + + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["effective_final"]["value"] == 0.85 + + def test_runtime_report_uses_objective_weight_unit(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() From c209e505a25e4e33a68dbf67dde2c46476a52e66 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 21:19:11 +0800 Subject: [PATCH 42/90] fix: materialize candidates before step setup --- agent/test/test_workspace_api.py | 13 +++++++++++-- agent/workspace_api.py | 18 ++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 4fdcc7877..0621df3e3 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -113,7 +113,8 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( calls = [] flows = [] - def build_flow(candidate_workspace): + def build_flow(candidate_workspace, *, create_step_workspaces=True): + assert create_step_workspaces is False root = Path(candidate_workspace.directory) flow = _Flow( candidate_workspace, @@ -144,6 +145,7 @@ def build_flow(candidate_workspace): ) def materialize(candidate_workspace, target, patch, candidate): + assert not flows[-1].created path = Path(candidate_workspace.directory) / "config" / "dreamplace.json" config = json.loads(path.read_text(encoding="utf-8")) config[patch[0]["knob_id"].removeprefix("place.")] = patch[0]["value"] @@ -211,6 +213,7 @@ def materialize(candidate_workspace, target, patch, candidate): candidate_root_ref = ".agent/candidates/candidate-1" candidate_manifest_ref = f"{candidate_root_ref}/analysis/candidate_workspace.v1.json" assert flows[0].run_calls == [("place", True), ("CTS", True)] + assert flows[0].created is True assert flow_path.read_bytes() == parent_flow_bytes assert config_path.read_text(encoding="utf-8") == '{"target_density": 0.5}\n' assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() @@ -431,9 +434,15 @@ def _close_transient_flow_db(self, _flow): class _Flow: def __init__(self, workspace, workspace_steps): self.workspace = workspace - self.workspace_steps = workspace_steps + self._workspace_steps = workspace_steps + self.workspace_steps = () + self.created = False self.run_calls = [] + def create_step_workspaces(self): + self.workspace_steps = self._workspace_steps + self.created = True + def get_step(self, name, tool): return next( ( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 9a284d49c..86388ad87 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -144,17 +144,17 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> ) flow = self._build_flow(candidate_workspace, create_step_workspaces=False) try: + if request.patch: + _materialize_candidate_rerun(candidate_workspace, flow, request) + create_step_workspaces = getattr(flow, "create_step_workspaces", None) + if callable(create_step_workspaces): + create_step_workspaces() steps = _candidate_rerun_steps( flow, request.target_step, request.end_step, request.execution_scope, ) - if request.patch: - _materialize_candidate_rerun(candidate_workspace, flow, request) - create_step_workspaces = getattr(flow, "create_step_workspaces", None) - if callable(create_step_workspaces): - create_step_workspaces() _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: @@ -662,10 +662,12 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest def _candidate_source_step(flow, target_step: str) -> str: - steps = list(getattr(flow, "workspace_steps", ())) + steps = flow.workspace.flow.data.get("steps", []) for index, step in enumerate(steps): - if step.name == target_step and index: - return steps[index - 1].name + if step.get("name") == target_step and index: + source = steps[index - 1].get("name") + if isinstance(source, str): + return source raise RuntimeApiError("invalid_request", f"candidate target has no predecessor: {target_step}") From cc45020f30bd68e9f33c7e3b1560f8c06454f005 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 27 Aug 2026 21:26:13 +0800 Subject: [PATCH 43/90] fix: preserve candidate configs during step setup --- agent/test/test_workspace_api.py | 14 ++++++++++---- agent/workspace_api.py | 6 +++--- chipcompiler/engine/flow.py | 10 ++++++++-- test/test_engine_flow.py | 23 +++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 0621df3e3..3e23aa13e 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -139,13 +139,16 @@ def build_flow(candidate_workspace, *, create_step_workspaces=True): monkeypatch.setattr("agent.workspace_api.build_agent_flow_for_workspace", build_flow) monkeypatch.setattr( "agent.workspace_api.bind_candidate_input", - lambda _ws, _flow, target, source, candidate: calls.append( - ("bind", target, source, candidate) + lambda _ws, _flow, target, source, candidate: ( + calls.append(("bind", target, source, candidate)) + if flows[-1].created + else pytest.fail("candidate steps must exist before input binding") ), ) def materialize(candidate_workspace, target, patch, candidate): - assert not flows[-1].created + assert flows[-1].created + assert flows[-1].initialize_config is False path = Path(candidate_workspace.directory) / "config" / "dreamplace.json" config = json.loads(path.read_text(encoding="utf-8")) config[patch[0]["knob_id"].removeprefix("place.")] = patch[0]["value"] @@ -214,6 +217,7 @@ def materialize(candidate_workspace, target, patch, candidate): candidate_manifest_ref = f"{candidate_root_ref}/analysis/candidate_workspace.v1.json" assert flows[0].run_calls == [("place", True), ("CTS", True)] assert flows[0].created is True + assert flows[0].initialize_config is False assert flow_path.read_bytes() == parent_flow_bytes assert config_path.read_text(encoding="utf-8") == '{"target_density": 0.5}\n' assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() @@ -437,11 +441,13 @@ def __init__(self, workspace, workspace_steps): self._workspace_steps = workspace_steps self.workspace_steps = () self.created = False + self.initialize_config = None self.run_calls = [] - def create_step_workspaces(self): + def create_step_workspaces(self, *, initialize_config=True): self.workspace_steps = self._workspace_steps self.created = True + self.initialize_config = initialize_config def get_step(self, name, tool): return next( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 86388ad87..03c8e4d46 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -144,11 +144,11 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> ) flow = self._build_flow(candidate_workspace, create_step_workspaces=False) try: - if request.patch: - _materialize_candidate_rerun(candidate_workspace, flow, request) create_step_workspaces = getattr(flow, "create_step_workspaces", None) if callable(create_step_workspaces): - create_step_workspaces() + create_step_workspaces(initialize_config=False) + if request.patch: + _materialize_candidate_rerun(candidate_workspace, flow, request) steps = _candidate_rerun_steps( flow, request.target_step, diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index bc9d4c5e3..e9279794c 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -315,13 +315,19 @@ def collect_signoff_package( """ return SignoffPackageCollector(self.workspace).collect(options) - def create_step_workspaces(self, *, executable_steps: set[str] | None = None): + def create_step_workspaces( + self, + *, + executable_steps: set[str] | None = None, + initialize_config: bool = True, + ): """ create all step workspaces executable_steps: names of the steps that will actually run. Only those steps verify tool dependencies; other steps are always built so the input/output chaining stays intact when a non-selected tool is absent. + initialize_config: whether step factories may regenerate tool configs. """ self.workspace_steps = [] pre_step = None @@ -359,7 +365,7 @@ def create_step_workspaces(self, *, executable_steps: set[str] | None = None): input_def=input_def, input_verilog=input_verilog, input_db=input_db, - initialize_config=True, + initialize_config=initialize_config, check_dependency=executable_steps is None or step["name"] in executable_steps, ) # save workspace step diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 588aa39ed..a3a489e5d 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -249,6 +249,29 @@ def fake_create_step(workspace, step, eda, **kwargs): assert sta_step.output.spef is rcx_output.spef # same object, per legacy contract +def test_create_step_workspaces_can_preserve_existing_configs(monkeypatch, tmp_path): + import chipcompiler.tools as tools_api + from chipcompiler.data import OriginDesign + + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + ) + initialize_config_values = [] + + def fake_create_step(workspace, step, eda, **kwargs): + initialize_config_values.append(kwargs["initialize_config"]) + return EccStep(name=step, tool=eda) + + monkeypatch.setattr(tools_api, "create_step", fake_create_step) + + flow = EngineFlow(workspace) + flow.workspace.flow.data = {"steps": [{"name": "Floorplan", "tool": "ecc"}]} + flow.create_step_workspaces(initialize_config=False) + + assert initialize_config_values == [False] + + # --- Phase 2: Silent failure regression tests --- From 4a43be717897543fb369b64caeef4fe4c65cafeb Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 28 Aug 2026 17:57:54 +0800 Subject: [PATCH 44/90] fix: strengthen dreamplace runtime evidence --- agent/data/parameter_application_receipt.py | 2 + .../test/test_parameter_receipt_artifacts.py | 69 ++++++ chipcompiler/tools/ecc_dreamplace/module.py | 232 +++++++++++++----- .../test_parameter_runtime_report.py | 145 ++++++++++- 4 files changed, 385 insertions(+), 63 deletions(-) diff --git a/agent/data/parameter_application_receipt.py b/agent/data/parameter_application_receipt.py index 171151915..6bcfa5034 100644 --- a/agent/data/parameter_application_receipt.py +++ b/agent/data/parameter_application_receipt.py @@ -59,6 +59,8 @@ def build_parameter_application_receipt( "effective_final", {"value": None, "unit": requested.get("unit", "")} ), } + if "consumer_observation" in runtime_report: + payload["consumer_observation"] = runtime_report["consumer_observation"] payload["evidence_sha256"] = _sha256(payload) if destination is not None: destination = Path(destination) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index bfc8a4c2e..d55cde448 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -7,6 +7,8 @@ from agent.data.candidate_artifacts import sha256_path from agent.workspace_api import _candidate_parameter_receipt +HASH = "sha256:" + "a" * 64 + def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> None: analysis = tmp_path / "analysis" @@ -70,3 +72,70 @@ def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monke parent_flow_sha256="sha256:" + "0" * 64, ) assert receipt["requested"] == {"knob_id": "place.cell_padding_x", "value": 1, "unit": "site"} + + +def test_candidate_receipt_preserves_native_consumer_observation_and_transition( + tmp_path: Path, +) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + materialization = analysis / "candidate_materialization.v1.json" + materialization.write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}], "configs": [{}]}), + encoding="utf-8", + ) + observation = { + "requested_target_density": 0.2, + "effective_target_density": 0.8, + "density_tensor_value": 0.8, + "placement_iteration_count": 4, + "evidence_complete": True, + } + transition = { + "sequence": 0, + "from": "materialized", + "to": "overridden", + "value": 0.8, + "reason": "DREAMPlace utilization lower bound", + "rule_id": "dreamplace.target_density.utilization_floor", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": HASH, + } + (analysis / "parameter_runtime_report.v1.json").write_text( + json.dumps( + { + "application_status": "applied", + "effective_initial": {"value": 0.8, "unit": "ratio"}, + "effective_final": {"value": 0.8, "unit": "ratio"}, + "activation": { + "status": "used", + "consumers": [ + { + "consumer_id": "dreamplace.density_objective", + "outcome": "entered", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": HASH, + } + ], + }, + "consumer_observation": observation, + "transitions": [transition], + } + ), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-floor", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.2}], + ) + + receipt = _candidate_parameter_receipt( + SimpleNamespace(directory=tmp_path), + request, + ".agent/candidates/candidate-floor", + materialization, + ) + + assert receipt["consumer_observation"] == observation + assert receipt["transitions"] == [transition] diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 9fdda87b4..75aaeb48a 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -6,7 +6,7 @@ import os import re import sys -from contextlib import contextmanager +from contextlib import contextmanager, suppress from pathlib import Path from chipcompiler.data import StepEnum, Workspace, WorkspaceStep @@ -121,12 +121,22 @@ def _run(self, *, legalize_only: bool) -> bool: ppa = engine.run() if ppa.get("hpwl") == float("inf"): - _write_parameter_runtime_report(self.workspace, params, engine_succeeded=False) + if not legalize_only: + _write_parameter_runtime_report( + self.workspace, engine.params, engine=engine, ppa=ppa + ) LOGGER = logging.getLogger(__name__) LOGGER.error("dreamplace failed for %s", self.step.name) return False - _write_parameter_runtime_report(self.workspace, params, engine_succeeded=True) + if not legalize_only: + _write_parameter_runtime_report( + self.workspace, + engine.params, + engine=engine, + ppa=ppa, + engine_succeeded=True, + ) return True def run_placement(self) -> bool: @@ -152,77 +162,183 @@ def _runtime_unit(knob_id: str) -> str: def _write_parameter_runtime_report( - workspace: Workspace, params, *, engine_succeeded: bool = False + workspace: Workspace, + params, + *, + engine=None, + ppa: dict | None = None, + engine_succeeded: bool = False, ) -> None: """Record the selected candidate knob at the native DreamPlace boundary.""" - report_path = Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" - materialization_path = ( - Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" - ) - if not materialization_path.is_file(): - return - try: - materialization = json.loads(materialization_path.read_text(encoding="utf-8")) - patch = materialization["patch"][0] - except (OSError, ValueError, KeyError, IndexError, TypeError): + patch = _candidate_patch(workspace) + if patch is None: return knob_id = patch.get("knob_id") - key_by_knob = { - "place.target_density": ("target_density", "dreamplace.density_objective"), - "place.target_overflow": ("stop_overflow", "dreamplace.overflow_predicate"), - "place.cell_padding_x": ("cell_padding_x", "dreamplace.cell_size_expansion"), - "place.routability_opt": ("routability_opt_flag", "dreamplace.routability_branch"), - "place.density_weight": ("density_weight", "dreamplace.density_preconditioner"), + consumer_by_knob = { + "place.target_density": "dreamplace.density_objective", + "place.target_overflow": "dreamplace.overflow_predicate", + "place.cell_padding_x": "dreamplace.cell_size_expansion", + "place.routability_opt": "dreamplace.routability_branch", + "place.density_weight": "dreamplace.density_preconditioner", } - if knob_id not in key_by_knob: + if knob_id not in consumer_by_knob: return - key, consumer_id = key_by_knob[knob_id] - value = getattr(params, key, None) - status = "used" if value is not None and engine_succeeded else "unknown" - branch_round_count = None - if knob_id == "place.routability_opt": - branch_round_count = _routability_branch_round_count(workspace) - if value in (False, 0): - status = "not_activated" - elif not engine_succeeded: - status = "unknown" - else: - status = "used" if branch_round_count else "not_activated" + consumer_id = consumer_by_knob[knob_id] + observation = _consumer_observation(workspace, knob_id, patch.get("value"), params, engine, ppa) + value = _effective_value(knob_id, params, observation) + status = _activation_status(knob_id, value, observation, engine_succeeded=engine_succeeded) + outcome = "evaluated" if knob_id == "place.target_overflow" or status != "used" else "entered" + evidence_payload = { + "consumer_id": consumer_id, + "outcome": outcome, + "consumer_observation": observation, + } evidence = { "consumer_id": consumer_id, - "outcome": "entered" if status == "used" else "evaluated", + "outcome": outcome, "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": _payload_sha256(evidence_payload), } - evidence["evidence_sha256"] = ( - "sha256:" - + hashlib.sha256( - json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - ) report = { "application_status": "applied" if value is not None else "unknown", - "effective_initial": { - "value": value, - "unit": _runtime_unit(knob_id), - }, - "effective_final": { - "value": value, - "unit": _runtime_unit(knob_id), - }, + "effective_initial": {"value": value, "unit": _runtime_unit(knob_id)}, + "effective_final": {"value": value, "unit": _runtime_unit(knob_id)}, "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, - "transitions": [], + "transitions": ( + _runtime_transitions(knob_id, patch.get("value"), value, evidence) + if status == "used" + else [] + ), + "consumer_observation": observation, } - if knob_id == "place.routability_opt": - report["consumer_observation"] = { - "branch_round_count": branch_round_count, - "evidence_complete": isinstance(branch_round_count, int), - } - report_path.parent.mkdir(parents=True, exist_ok=True) - temporary = report_path.with_suffix(report_path.suffix + ".tmp") + report_path = Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" + _write_json_atomic(report_path, report) + + +def _candidate_patch(workspace: Workspace) -> dict | None: + path = Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8"))["patch"][0] + except (OSError, ValueError, KeyError, IndexError, TypeError): + return None + + +def _write_json_atomic(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( - json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" + json.dumps(payload, sort_keys=True, separators=(",", ":")), encoding="utf-8" ) - os.replace(temporary, report_path) + os.replace(temporary, path) + + +def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> dict: + ppa = ppa if isinstance(ppa, dict) else {} + iterations = ppa.get("iteration") + valid_iterations = type(iterations) is int and iterations > 0 + if knob_id == "place.target_density": + data = getattr( + getattr(getattr(engine, "placer", None), "data_collections", None), + "target_density", + None, + ) + tensor_value = _scalar_value(data) + effective = _scalar_value(getattr(params, "target_density", None)) + return { + "requested_target_density": requested, + "effective_target_density": effective, + "density_tensor_value": tensor_value, + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations and tensor_value == effective, + } + if knob_id == "place.target_overflow": + return { + "effective_stop_overflow": _scalar_value(getattr(params, "stop_overflow", None)), + "final_overflow": _scalar_value(ppa.get("overflow")), + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations + and _scalar_value(ppa.get("overflow")) is not None, + } + if knob_id == "place.cell_padding_x": + placedb = getattr(engine, "placedb", None) + effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) + movable = getattr(placedb, "num_movable_nodes", None) + return { + "requested_padding_site": requested, + "effective_padding_dbu": effective, + "movable_node_count": movable, + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations + and effective is not None + and type(movable) is int, + } + if knob_id == "place.density_weight": + return { + "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), + "final_objective": _scalar_value(ppa.get("objective")), + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations + and _scalar_value(ppa.get("objective")) is not None, + } + rounds = _routability_branch_round_count(workspace) + return {"branch_round_count": rounds, "evidence_complete": isinstance(rounds, int)} + + +def _effective_value(knob_id: str, params, observation: dict): + if knob_id == "place.target_density": + return observation["effective_target_density"] + if knob_id == "place.cell_padding_x": + return observation["effective_padding_dbu"] + key = { + "place.target_overflow": "stop_overflow", + "place.routability_opt": "routability_opt_flag", + "place.density_weight": "density_weight", + }[knob_id] + return _scalar_value(getattr(params, key, None)) + + +def _activation_status(knob_id: str, value, observation: dict, *, engine_succeeded: bool) -> str: + if knob_id == "place.routability_opt" and value in (False, 0): + return "not_activated" + if not engine_succeeded or not observation.get("evidence_complete"): + return "unknown" + if knob_id == "place.routability_opt" and not observation.get("branch_round_count"): + return "not_activated" + if knob_id == "place.cell_padding_x" and value == 0: + return "not_activated" + return "used" + + +def _runtime_transitions(knob_id: str, requested, effective, evidence: dict) -> list[dict]: + if knob_id != "place.target_density" or not isinstance(requested, (int, float)): + return [] + if not isinstance(effective, (int, float)) or effective <= requested: + return [] + return [ + { + "sequence": 0, + "from": "materialized", + "to": "overridden", + "value": effective, + "reason": "DREAMPlace utilization lower bound", + "rule_id": "dreamplace.target_density.utilization_floor", + "evidence_ref": evidence["evidence_ref"], + "evidence_sha256": evidence["evidence_sha256"], + } + ] + + +def _scalar_value(value): + with suppress(AttributeError): + value = value.item() + return value if type(value) in {bool, int, float} else None + + +def _payload_sha256(payload: dict) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return "sha256:" + hashlib.sha256(encoded).hexdigest() def _routability_branch_round_count(workspace: Workspace) -> int | None: diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index ec14587ea..c252aaafa 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -6,6 +6,22 @@ from chipcompiler.tools.ecc_dreamplace.module import _write_parameter_runtime_report +class _Scalar: + def __init__(self, value): + self.value = value + + def item(self): + return self.value + + +def _engine(*, target_density=None, cell_padding_x=None): + data_collections = SimpleNamespace(target_density=_Scalar(target_density)) + return SimpleNamespace( + placer=SimpleNamespace(data_collections=data_collections), + placedb=SimpleNamespace(cell_padding_x=cell_padding_x, num_movable_nodes=12), + ) + + def test_runtime_report_records_native_density_consumer(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() @@ -15,11 +31,54 @@ def test_runtime_report_records_native_density_consumer(tmp_path): ) params = SimpleNamespace(target_density=0.85) _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), params, engine_succeeded=True + SimpleNamespace(directory=tmp_path), + params, + engine=_engine(target_density=0.85), + ppa={"iteration": 3}, + engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "used" assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" + assert report["consumer_observation"] == { + "density_tensor_value": 0.85, + "effective_target_density": 0.85, + "evidence_complete": True, + "placement_iteration_count": 3, + "requested_target_density": 0.85, + } + + +def test_runtime_report_records_density_utilization_floor_transition(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}]}), + encoding="utf-8", + ) + + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(target_density=0.8), + engine=_engine(target_density=0.8), + ppa={"iteration": 4}, + engine_succeeded=True, + ) + + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["effective_initial"] == {"unit": "ratio", "value": 0.8} + assert report["transitions"] == [ + { + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": report["activation"]["consumers"][0]["evidence_sha256"], + "from": "materialized", + "reason": "DREAMPlace utilization lower bound", + "rule_id": "dreamplace.target_density.utilization_floor", + "sequence": 0, + "to": "overridden", + "value": 0.8, + } + ] def test_runtime_report_does_not_parse_logged_parameter_values(tmp_path): @@ -36,6 +95,8 @@ def test_runtime_report_does_not_parse_logged_parameter_values(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(target_density=0.85), + engine=_engine(target_density=0.85), + ppa={"iteration": 2}, engine_succeeded=True, ) @@ -53,11 +114,74 @@ def test_runtime_report_uses_objective_weight_unit(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(density_weight=0.001), + engine=SimpleNamespace(), + ppa={"iteration": 5, "objective": 12.5}, engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["effective_initial"]["unit"] == "objective_weight" assert report["effective_final"]["unit"] == "objective_weight" + assert report["consumer_observation"] == { + "configured_density_weight": 0.001, + "evidence_complete": True, + "final_objective": 12.5, + "placement_iteration_count": 5, + } + + +def test_runtime_report_records_overflow_predicate_evaluation(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_overflow", "value": 0.1}]}), + encoding="utf-8", + ) + + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(stop_overflow=0.1), + engine=SimpleNamespace(), + ppa={"iteration": 7, "overflow": 0.08}, + engine_succeeded=True, + ) + + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "used" + assert report["activation"]["consumers"][0]["outcome"] == "evaluated" + assert report["consumer_observation"] == { + "effective_stop_overflow": 0.1, + "evidence_complete": True, + "final_overflow": 0.08, + "placement_iteration_count": 7, + } + + +def test_runtime_report_preserves_consumed_cell_padding_after_restore(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.cell_padding_x", "value": 400}]}), + encoding="utf-8", + ) + + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(cell_padding_x=0), + engine=_engine(target_density=0.8, cell_padding_x=200), + ppa={"iteration": 3}, + engine_succeeded=True, + ) + + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["effective_initial"] == {"unit": "dbu", "value": 200} + assert report["activation"]["status"] == "used" + assert report["consumer_observation"] == { + "effective_padding_dbu": 200, + "evidence_complete": True, + "movable_node_count": 12, + "placement_iteration_count": 3, + "requested_padding_site": 400, + } def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): @@ -68,7 +192,10 @@ def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): encoding="utf-8", ) _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), SimpleNamespace(routability_opt_flag=False) + SimpleNamespace(directory=tmp_path), + SimpleNamespace(routability_opt_flag=False), + engine=SimpleNamespace(), + ppa={"iteration": 3}, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "not_activated" @@ -84,10 +211,12 @@ def test_runtime_report_requires_a_native_routability_round(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(routability_opt_flag=True), + engine=SimpleNamespace(), + ppa={"iteration": 3}, engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "not_activated" + assert report["activation"]["status"] == "unknown" assert report["consumer_observation"]["evidence_complete"] is False log_dir = tmp_path / "place_dreamplace" / "log" @@ -99,6 +228,8 @@ def test_runtime_report_requires_a_native_routability_round(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(routability_opt_flag=True), + engine=SimpleNamespace(), + ppa={"iteration": 3}, engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) @@ -110,11 +241,15 @@ def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}]}), encoding="utf-8", ) _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), SimpleNamespace(target_density=0.85) + SimpleNamespace(directory=tmp_path), + SimpleNamespace(target_density=0.8), + engine=_engine(target_density=0.8), + ppa={"iteration": 3}, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"] == {"status": "unknown", "consumers": []} + assert report["transitions"] == [] From b6057e1a5fc40e38db14e38475637e52bdfa82bc Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 28 Aug 2026 18:41:16 +0800 Subject: [PATCH 45/90] fix: bind candidate materialization evidence --- agent/data/candidate_materialization.py | 230 +++++++++---- agent/data/parameter_application_receipt.py | 20 +- agent/requests.py | 2 + .../data/test_candidate_materialization.py | 207 ++++++++++-- .../test/test_parameter_receipt_artifacts.py | 154 +++++++-- agent/test/test_requests.py | 23 +- agent/test/test_workspace_api.py | 262 ++++++++++++++- agent/workspace_api.py | 313 ++++++++++++------ chipcompiler/runtime/operations.py | 45 ++- test/runtime/test_operations.py | 55 ++- 10 files changed, 1086 insertions(+), 225 deletions(-) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 89e197359..f99809057 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -1,6 +1,7 @@ """Controlled, replayable config overlays for isolated ECC candidate workspaces.""" import math +import re import shutil from copy import deepcopy from pathlib import Path @@ -39,13 +40,19 @@ def materialize_candidate_config( candidate_id: str, ) -> dict[str, Any]: candidate_id = _validated_candidate_id(candidate_id) - normalized_patch = _normalize_patch(patch) - knobs = _resolve_knobs(target_step, normalized_patch, workspace) + normalized_patch, knobs = _prepare_patch(workspace, target_step, patch) configs, config_paths, before_hashes = _load_configs(workspace, knobs) before_configs = deepcopy(configs) _apply_patch(configs, knobs, normalized_patch) + if configs == before_configs: + raise CandidateMaterializationError("candidate materialization patch did not change config") + snapshots = _write_config_snapshots( + workspace, + candidate_id, + config_paths, + configs, + ) after_hashes = _write_configs(workspace, configs, config_paths) - snapshots = _write_config_snapshots(workspace, candidate_id, before_configs, configs) receipt = _build_receipt( workspace, target_step, @@ -69,57 +76,28 @@ def reapply_materialized_candidate_config( if not receipt_path.exists(): return None receipt = _read_receipt(receipt_path) - if receipt["target"]["step"] != target_step: - return None - current_configs = receipt.get("configs", []) - snapshots = receipt.get("snapshots", []) - if current_configs and snapshots: - _verify_config_snapshot_hashes(workspace, snapshots) - snapshot_by_key = { - item["config_key"]: item - for item in snapshots - if isinstance(item, dict) and isinstance(item.get("config_key"), str) - } - for entry in current_configs: - snapshot = snapshot_by_key.get(entry.get("config_key")) - if snapshot is None: - raise CandidateMaterializationError("candidate config snapshot is incomplete") - after_ref = snapshot.get("after_ref") - if not isinstance(after_ref, str): - raise CandidateMaterializationError("candidate config snapshot ref is invalid") - after_path = Path(workspace.directory) / after_ref - shutil.copyfile(after_path, _config_path(workspace, entry["config_key"])) - return receipt - normalized_patch = receipt["patch"] - knobs = _resolve_knobs(target_step, normalized_patch, workspace) - configs, config_paths, before_hashes = _load_configs(workspace, knobs) - before_configs = deepcopy(configs) - _apply_patch(configs, knobs, normalized_patch) - after_hashes = _write_configs(workspace, configs, config_paths) - snapshots = _write_config_snapshots( - workspace, - receipt["candidate_id"], - before_configs, - configs, - ) - updated = _build_receipt( - workspace, - target_step, - receipt["candidate_id"], - normalized_patch, - knobs, - config_paths, - before_hashes, - after_hashes, - snapshots, - ) - write_json_atomic(receipt_path, updated) - return updated + _validate_receipt_binding(workspace, target_step, receipt) + _verify_config_snapshot_hashes(workspace, receipt["snapshots"]) + snapshots = {entry["config_key"]: entry for entry in receipt["snapshots"]} + for entry in receipt["configs"]: + config_key = entry["config_key"] + after_path = Path(workspace.directory) / snapshots[config_key]["after_ref"] + config_path = _config_path(workspace, config_key) + shutil.copyfile(after_path, config_path) + if config_key == "parameters" and hasattr(workspace, "parameters"): + try: + workspace.parameters.data = read_json_object(config_path, "candidate parameters") + except ValueError as error: + raise CandidateMaterializationError(str(error)) from error + _verify_materialized_config_hashes(workspace, receipt["configs"]) + return receipt def _normalize_patch(patch: Any) -> list[dict[str, Any]]: if not isinstance(patch, list) or not patch: raise CandidateMaterializationError("patch must be a non-empty list") + if len(patch) != 1: + raise CandidateMaterializationError("patch must contain exactly one knob") normalized: list[dict[str, Any]] = [] knob_ids: set[str] = set() for item in patch: @@ -143,6 +121,55 @@ def _normalize_patch(patch: Any) -> list[dict[str, Any]]: return sorted(normalized, key=lambda item: item["knob_id"]) +def candidate_written_patch( + workspace: Any, + target_step: str, + patch: Any, +) -> list[dict[str, Any]]: + """Validate a surface patch and return the values written by L1.""" + return _prepare_patch(workspace, target_step, patch)[0] + + +def _prepare_patch( + workspace: Any, + target_step: str, + patch: Any, +) -> tuple[list[dict[str, Any]], list[CandidateKnob]]: + normalized = _normalize_patch(patch) + knobs = _resolve_knobs(target_step, normalized, workspace) + written = [dict(item) for item in normalized] + if written[0]["knob_id"] == "place.cell_padding_x": + written[0]["value"] *= _site_width_dbu(workspace) + return written, knobs + + +def _site_width_dbu(workspace: Any) -> int: + pdk = getattr(workspace, "pdk", None) + tech = getattr(pdk, "tech", None) + site_name = getattr(pdk, "site_core", None) + if not tech or not isinstance(site_name, str) or not site_name: + raise CandidateMaterializationError("workspace placement site is unavailable") + try: + text = Path(tech).read_text(encoding="utf-8") + except OSError as error: + raise CandidateMaterializationError("workspace tech LEF is unavailable") from error + units = re.search(r"DATABASE\s+MICRONS\s+(\d+)", text, re.IGNORECASE) + site = re.search( + rf"SITE\s+{re.escape(site_name)}\b(?P.*?)END\s+{re.escape(site_name)}\b", + text, + re.IGNORECASE | re.DOTALL, + ) + size = re.search( + r"SIZE\s+([0-9]+(?:\.[0-9]+)?)\s+BY", + site.group("body") if site else "", + re.IGNORECASE, + ) + width = round(float(units.group(1)) * float(size.group(1))) if units and size else 0 + if width <= 0: + raise CandidateMaterializationError("workspace placement site width is unavailable") + return width + + def _resolve_knobs( target_step: str, patch: list[dict[str, Any]], @@ -346,14 +373,15 @@ def _write_configs( def _write_config_snapshots( workspace: Any, candidate_id: str, - before_configs: dict[str, dict[str, Any]], + config_paths: dict[str, Path], after_configs: dict[str, dict[str, Any]], ) -> list[dict[str, str]]: snapshots: list[dict[str, str]] = [] for config_key in sorted(after_configs): before_path = _snapshot_path(workspace, candidate_id, config_key, "before") after_path = _snapshot_path(workspace, candidate_id, config_key, "after") - write_json_atomic(before_path, before_configs[config_key]) + before_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(config_paths[config_key], before_path) write_json_atomic(after_path, after_configs[config_key]) before_sha256 = sha256_path(before_path) after_sha256 = sha256_path(after_path) @@ -428,17 +456,23 @@ def materialized_candidate_id(workspace: Any, target_step: str) -> str | None: def validate_materialized_candidate_config(workspace: Any, target_step: str) -> str | None: """Verify the materialized config still matches its immutable receipt.""" + receipt = validate_candidate_materialization_receipt(workspace, target_step) + return receipt["candidate_id"] if receipt is not None else None + + +def validate_candidate_materialization_receipt( + workspace: Any, + target_step: str, +) -> dict[str, Any] | None: + """Read and strictly bind an immutable L1 receipt to the current workspace.""" receipt_path = _receipt_path(workspace) if not receipt_path.exists(): return None receipt = _read_receipt(receipt_path) - if receipt["target_step"] != target_step: - return None - _require_candidate_target_backend(workspace, target_step) + _validate_receipt_binding(workspace, target_step, receipt) _verify_materialized_config_hashes(workspace, receipt["configs"]) - if snapshots := receipt.get("snapshots"): - _verify_config_snapshot_hashes(workspace, snapshots) - return receipt["candidate_id"] + _verify_config_snapshot_hashes(workspace, receipt["snapshots"]) + return receipt def _read_receipt(path: Path) -> dict[str, Any]: @@ -478,13 +512,70 @@ def _read_receipt(path: Path) -> dict[str, Any]: if receipt.get("receipt_sha256") != _receipt_digest(receipt): raise CandidateMaterializationError("candidate materialization receipt hash is invalid") _validate_config_receipts(receipt.get("configs")) - snapshots = receipt.get("snapshots") - if snapshots is not None: - _validate_snapshot_receipts(snapshots) + _validate_snapshot_receipts(receipt.get("snapshots")) receipt["candidate_id"] = candidate_id return receipt +def _validate_receipt_binding( + workspace: Any, + target_step: str, + receipt: dict[str, Any], +) -> None: + if receipt["target_step"] != target_step: + raise CandidateMaterializationError("candidate materialization target step mismatch") + knobs = _resolve_knobs(target_step, receipt["patch"], workspace) + configs = _entries_by_config_key(receipt["configs"], "config") + snapshots = _entries_by_config_key(receipt["snapshots"], "snapshot") + expected_keys = {knob.config_key for knob in knobs} + if set(configs) != expected_keys or set(snapshots) != expected_keys: + raise CandidateMaterializationError( + "candidate materialization configs and snapshots are incomplete" + ) + for config_key in expected_keys: + _validate_bound_config(workspace, receipt["candidate_id"], config_key, configs, snapshots) + + +def _entries_by_config_key(entries: list[dict[str, Any]], label: str) -> dict[str, dict[str, Any]]: + keyed = {entry["config_key"]: entry for entry in entries} + if len(keyed) != len(entries): + raise CandidateMaterializationError( + f"candidate materialization {label} keys are duplicated" + ) + return keyed + + +def _validate_bound_config( + workspace: Any, + candidate_id: str, + config_key: str, + configs: dict[str, dict[str, Any]], + snapshots: dict[str, dict[str, Any]], +) -> None: + config = configs[config_key] + snapshot = snapshots[config_key] + expected_ref = workspace_relative_ref(workspace.directory, _config_path(workspace, config_key)) + if config["ref"] != expected_ref: + raise CandidateMaterializationError( + "candidate materialization config ref does not match registry" + ) + if any( + config[f"{state}_sha256"] != snapshot[f"{state}_sha256"] for state in ("before", "after") + ): + raise CandidateMaterializationError( + "candidate materialization config snapshot hashes do not match" + ) + for state in ("before", "after"): + expected = workspace_relative_ref( + workspace.directory, + _snapshot_path(workspace, candidate_id, config_key, state), + ) + if snapshot[f"{state}_ref"] != expected: + raise CandidateMaterializationError( + "candidate materialization snapshot ref does not match candidate" + ) + + def _validate_config_receipts(configs: Any) -> None: if not isinstance(configs, list) or not configs: raise CandidateMaterializationError("candidate materialization receipt configs are invalid") @@ -502,10 +593,7 @@ def _validate_config_receipts(configs: Any) -> None: raise CandidateMaterializationError("candidate materialization config key is invalid") if not isinstance(entry["ref"], str) or not entry["ref"]: raise CandidateMaterializationError("candidate materialization config ref is invalid") - if not all( - isinstance(entry[key], str) and entry[key].startswith("sha256:") - for key in ("before_sha256", "after_sha256") - ): + if not all(_is_sha256(entry[key]) for key in ("before_sha256", "after_sha256")): raise CandidateMaterializationError("candidate materialization config hash is invalid") if entry["before_sha256"] == entry["after_sha256"]: raise CandidateMaterializationError( @@ -531,10 +619,7 @@ def _validate_snapshot_receipts(snapshots: Any) -> None: isinstance(entry[key], str) and entry[key] for key in ("before_ref", "after_ref") ): raise CandidateMaterializationError("candidate config snapshot ref is invalid") - if not all( - isinstance(entry[key], str) and entry[key].startswith("sha256:") - for key in ("before_sha256", "after_sha256") - ): + if not all(_is_sha256(entry[key]) for key in ("before_sha256", "after_sha256")): raise CandidateMaterializationError("candidate config snapshot hash is invalid") if entry["before_sha256"] == entry["after_sha256"]: raise CandidateMaterializationError("candidate config snapshot did not change config") @@ -570,6 +655,15 @@ def _verify_config_snapshot_hashes(workspace: Any, snapshots: list[dict[str, str raise CandidateMaterializationError("candidate config snapshot drift") +def _is_sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 71 + and value.startswith("sha256:") + and all(character in "0123456789abcdef" for character in value[7:]) + ) + + def _receipt_digest(receipt: dict[str, Any]) -> str: payload = {key: value for key, value in receipt.items() if key != "receipt_sha256"} return sha256_bytes(canonical_json_bytes(payload)) diff --git a/agent/data/parameter_application_receipt.py b/agent/data/parameter_application_receipt.py index 6bcfa5034..e06a5d849 100644 --- a/agent/data/parameter_application_receipt.py +++ b/agent/data/parameter_application_receipt.py @@ -39,7 +39,16 @@ def build_parameter_application_receipt( if activation.get("status") == "used" and not activation.get("consumers"): raise ValueError("used activation requires consumer evidence") normalized_tool = dict(tool) - normalized_tool.setdefault("source_sha256", None) + required_tool = ("name", "revision", "source_sha256") + if any( + not isinstance(normalized_tool.get(key), str) or not normalized_tool[key].strip() + for key in required_tool + ): + raise ValueError("complete tool metadata is required") + if normalized_tool["revision"] == "bound": + raise ValueError("bound tool metadata is not allowed") + if not _is_sha256(normalized_tool["source_sha256"]): + raise ValueError("tool source_sha256 is invalid") normalized_materialization = dict(materialization) normalized_materialization.setdefault("parent_ref", None) payload: dict[str, Any] = { @@ -71,3 +80,12 @@ def build_parameter_application_receipt( ) os.replace(temporary, destination) return payload + + +def _is_sha256(value: str) -> bool: + digest = value.removeprefix("sha256:") + return ( + value.startswith("sha256:") + and len(digest) == 64 + and all(character in "0123456789abcdef" for character in digest) + ) diff --git a/agent/requests.py b/agent/requests.py index b92c3bc88..237b61b2c 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -34,6 +34,7 @@ class CandidateRerunRequest: patch: list[dict[str, Any]] execution_scope: str idempotency_key: str + context_sha256: str parent_candidate_root_ref: str | None = None @@ -45,6 +46,7 @@ class CandidateRerunRequest: "candidateId": "candidate_id", "executionScope": "execution_scope", "idempotencyKey": "idempotency_key", + "contextSha256": "context_sha256", "parentCandidateRootRef": "parent_candidate_root_ref", } diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index 6d02d51db..1d3995331 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -5,12 +5,14 @@ import pytest +from agent.data.candidate_artifacts import canonical_json_bytes, sha256_bytes from agent.data.candidate_capabilities import export_candidate_capabilities from agent.data.candidate_materialization import ( CandidateMaterializationError, candidate_knob_registry, materialize_candidate_config, reapply_materialized_candidate_config, + validate_candidate_materialization_receipt, validate_materialized_candidate_config, ) from agent.data.candidate_registry import candidate_capability_registry @@ -31,7 +33,22 @@ def _sha256(path: Path) -> str: return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" +def _rewrite_receipt(path: Path, receipt: dict) -> None: + receipt["receipt_sha256"] = sha256_bytes( + canonical_json_bytes( + {key: value for key, value in receipt.items() if key != "receipt_sha256"} + ) + ) + _write_json(path, receipt) + + def _workspace(tmp_path: Path): + tech_path = tmp_path / "pdk" / "tech.lef" + tech_path.parent.mkdir(parents=True) + tech_path.write_text( + "UNITS\n DATABASE MICRONS 1000 ;\nEND UNITS\nSITE core7\n SIZE 0.2 BY 1.4 ;\nEND core7\n", + encoding="utf-8", + ) cts_path = tmp_path / "config" / "cts_ecc.json" pl_path = tmp_path / "config" / "filler_ecc.json" _write_json( @@ -83,7 +100,12 @@ def _workspace(tmp_path: Path): "filler": pl_path, "route": tmp_path / "config" / "route_ecc.json", }, - pdk=SimpleNamespace(buffers=["BUF_1", "BUF_2"], fillers=["FILL_1", "FILL_2"]), + pdk=SimpleNamespace( + buffers=["BUF_1", "BUF_2"], + fillers=["FILL_1", "FILL_2"], + site_core="core7", + tech=tech_path, + ), parameters=SimpleNamespace(path=parameters_path), flow=SimpleNamespace( data={ @@ -144,11 +166,7 @@ def test_materialize_cts_overlay_preserves_base_config_and_writes_receipt(tmp_pa receipt = materialize_candidate_config( workspace, "CTS", - [ - {"knob_id": "cts.max_fanout", "value": 48}, - {"knob_id": "cts.buffer_type", "value": ["BUF_2"]}, - {"knob_id": "cts.skew_bound", "value": 0.12}, - ], + [{"knob_id": "cts.skew_bound", "value": 0.12}], candidate_id="cts-rerun-001", ) @@ -158,8 +176,8 @@ def test_materialize_cts_overlay_preserves_base_config_and_writes_receipt(tmp_pa persisted = _read_json(receipt_path) assert config["skew_bound"] == 0.12 - assert config["max_fanout"] == 48 - assert config["buffer_type"] == ["BUF_2"] + assert config["max_fanout"] == "32" + assert config["buffer_type"] == ["BUF_1"] assert config["unrelated"] == {"keep": True} assert receipt == persisted assert receipt["schema"] == "ecc.workspace.candidate_materialization.v1" @@ -167,11 +185,7 @@ def test_materialize_cts_overlay_preserves_base_config_and_writes_receipt(tmp_pa assert receipt["candidate_id"] == "cts-rerun-001" assert receipt["target_step"] == "CTS" assert receipt["target"] == {"step": "CTS"} - assert receipt["patch"] == [ - {"knob_id": "cts.buffer_type", "value": ["BUF_2"]}, - {"knob_id": "cts.max_fanout", "value": 48}, - {"knob_id": "cts.skew_bound", "value": 0.12}, - ] + assert receipt["patch"] == [{"knob_id": "cts.skew_bound", "value": 0.12}] assert receipt["registry_sha256"].startswith("sha256:") assert receipt["patch_sha256"].startswith("sha256:") assert receipt["receipt_sha256"].startswith("sha256:") @@ -191,15 +205,12 @@ def test_materialize_legalization_overlay_targets_real_dreamplace_config(tmp_pat receipt = materialize_candidate_config( workspace, "legalization", - [ - {"knob_id": "legalization.bndry_padding_x", "value": 4}, - {"knob_id": "legalization.detailed_place_flag", "value": True}, - ], + [{"knob_id": "legalization.detailed_place_flag", "value": True}], candidate_id="legalization-candidate", ) config = _read_json(workspace.config["dreamplace"]) - assert config["bndry_padding_x"] == 4 + assert config["bndry_padding_x"] == 0 assert config["detailed_place_flag"] == 1 assert receipt["configs"][0]["config_key"] == "dreamplace" assert receipt["configs"][0]["ref"] == "config/dreamplace_ecc.json" @@ -225,6 +236,163 @@ def test_materialization_preserves_complete_before_and_after_config_snapshots(tm assert snapshot["after_sha256"] == _sha256(workspace.config["dreamplace"]) +def test_materialize_rejects_multiple_knobs_without_writing_artifacts(tmp_path): + workspace = _workspace(tmp_path) + before = _read_json(workspace.config["CTS"]) + + with pytest.raises(CandidateMaterializationError, match="exactly one knob"): + materialize_candidate_config( + workspace, + "CTS", + [ + {"knob_id": "cts.skew_bound", "value": 0.12}, + {"knob_id": "cts.max_fanout", "value": 48}, + ], + candidate_id="multi-knob-candidate", + ) + + assert _read_json(workspace.config["CTS"]) == before + assert not (tmp_path / "analysis" / "candidate_materialization.v1.json").exists() + + +def test_materialize_rejects_noop_without_writing_artifacts(tmp_path): + workspace = _workspace(tmp_path) + before = workspace.config["dreamplace"].read_bytes() + + with pytest.raises(CandidateMaterializationError, match="did not change config"): + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.8}], + candidate_id="noop-candidate", + ) + + assert workspace.config["dreamplace"].read_bytes() == before + assert not (tmp_path / "analysis" / "candidate_materialization.v1.json").exists() + assert not (tmp_path / "analysis" / "candidate_config_snapshots.v1").exists() + + +def test_materialize_converts_padding_sites_to_written_dbu(tmp_path): + workspace = _workspace(tmp_path) + + receipt = materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.cell_padding_x", "value": 2}], + candidate_id="padding-candidate", + ) + + assert _read_json(workspace.config["dreamplace"])["cell_padding_x"] == 400 + assert receipt["patch"] == [{"knob_id": "place.cell_padding_x", "value": 400}] + + +def test_receipt_target_mismatch_is_fail_closed(tmp_path): + workspace = _workspace(tmp_path) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + + with pytest.raises(CandidateMaterializationError, match="target step mismatch"): + validate_candidate_materialization_receipt(workspace, "route") + with pytest.raises(CandidateMaterializationError, match="target step mismatch"): + reapply_materialized_candidate_config(workspace, "route") + + +@pytest.mark.parametrize( + ("knob_id", "value", "error"), + [ + ("route.thread_number", 4, "not valid for target step"), + ("place.target_density", 2.0, "must be <="), + ], +) +def test_validated_receipt_rechecks_knob_target_and_value(tmp_path, knob_id, value, error): + workspace = _workspace(tmp_path) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + receipt_path = tmp_path / "analysis" / "candidate_materialization.v1.json" + receipt = _read_json(receipt_path) + receipt["patch"] = [{"knob_id": knob_id, "value": value}] + receipt["patch_sha256"] = sha256_bytes(canonical_json_bytes(receipt["patch"])) + _rewrite_receipt(receipt_path, receipt) + + with pytest.raises(CandidateMaterializationError, match=error): + validate_candidate_materialization_receipt(workspace, "place") + + +def test_validated_receipt_requires_the_registry_config_path(tmp_path): + workspace = _workspace(tmp_path) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + receipt_path = tmp_path / "analysis" / "candidate_materialization.v1.json" + receipt = _read_json(receipt_path) + alternate = tmp_path / "config" / "alternate.json" + alternate.write_bytes(workspace.config["dreamplace"].read_bytes()) + receipt["configs"][0]["ref"] = "config/alternate.json" + _rewrite_receipt(receipt_path, receipt) + + with pytest.raises(CandidateMaterializationError, match="config ref does not match registry"): + validate_candidate_materialization_receipt(workspace, "place") + + +@pytest.mark.parametrize( + "tamper", + ["snapshot_key", "incomplete_hash", "before_hash_mismatch", "missing_snapshots"], +) +def test_validated_receipt_requires_complete_one_to_one_config_snapshots(tmp_path, tamper): + workspace = _workspace(tmp_path) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.7}], + candidate_id="place-candidate", + ) + receipt_path = tmp_path / "analysis" / "candidate_materialization.v1.json" + receipt = _read_json(receipt_path) + if tamper == "snapshot_key": + receipt["snapshots"][0]["config_key"] = "CTS" + elif tamper == "incomplete_hash": + receipt["configs"][0]["before_sha256"] = "sha256:x" + elif tamper == "before_hash_mismatch": + receipt["snapshots"][0]["before_sha256"] = "sha256:" + "a" * 64 + else: + receipt["snapshots"] = [] + _rewrite_receipt(receipt_path, receipt) + + with pytest.raises(CandidateMaterializationError): + validate_candidate_materialization_receipt(workspace, "place") + + +def test_reapply_keeps_in_memory_parameters_consistent(tmp_path): + workspace = _workspace(tmp_path) + workspace.parameters.data = _read_json(workspace.parameters.path) + materialize_candidate_config( + workspace, + "Floorplan", + [{"knob_id": "floorplan.core_util", "value": 0.7}], + candidate_id="floorplan-candidate", + ) + refreshed = _read_json(workspace.parameters.path) + refreshed["Core"]["Utilitization"] = 0.6 + _write_json(workspace.parameters.path, refreshed) + workspace.parameters.data = refreshed + + reapply_materialized_candidate_config(workspace, "Floorplan") + + assert workspace.parameters.data == _read_json(workspace.parameters.path) + assert workspace.parameters.data["Core"]["Utilitization"] == 0.7 + + def test_reapply_keeps_receipt_when_tool_rewrites_equivalent_json(tmp_path): workspace = _workspace(tmp_path) original = materialize_candidate_config( @@ -317,7 +485,8 @@ def test_reapply_after_refresh_restores_only_matching_target_and_updates_hashes( current[path[-1]] = reset_value _write_json(config_path, refreshed_config) - assert reapply_materialized_candidate_config(workspace, "route") is None + with pytest.raises(CandidateMaterializationError, match="target step mismatch"): + reapply_materialized_candidate_config(workspace, "route") unchanged = _read_json(config_path) current = unchanged for key in path: diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index d55cde448..5b36b2655 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -4,33 +4,91 @@ from pathlib import Path from types import SimpleNamespace +import pytest + from agent.data.candidate_artifacts import sha256_path +from agent.data.candidate_materialization import ( + CandidateMaterializationError, + materialize_candidate_config, +) +from agent.data.parameter_application_receipt import build_parameter_application_receipt from agent.workspace_api import _candidate_parameter_receipt HASH = "sha256:" + "a" * 64 +PRODUCER = Path(__file__).parents[2] / "chipcompiler/tools/ecc_dreamplace/module.py" +TOOL = { + "name": "DREAMPlace", + "revision": "ecc.dreamplace.parameter_runtime_report.v2", + "source_sha256": sha256_path(PRODUCER), +} -def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> None: - analysis = tmp_path / "analysis" - analysis.mkdir() - materialization = analysis / "candidate_materialization.v1.json" - materialization.write_text( +def _write_unknown_runtime_report(analysis: Path) -> None: + (analysis / "parameter_runtime_report.v1.json").write_text( json.dumps( { - "patch": [{"knob_id": "place.target_density", "value": 0.85}], - "configs": [{}], + "tool": TOOL, + "application_status": "unknown", + "activation": {"status": "unknown", "consumers": []}, + "effective_initial": {"value": None, "unit": "ratio"}, + "effective_final": {"value": None, "unit": "ratio"}, } ), encoding="utf-8", ) + + +def _materialized_workspace( + tmp_path: Path, + *, + candidate_id: str, + knob_id: str, + before: object, + written: object, +) -> tuple[SimpleNamespace, Path]: + tech = tmp_path / "pdk" / "tech.lef" + tech.parent.mkdir(parents=True) + tech.write_text( + "UNITS\n DATABASE MICRONS 1000 ;\nEND UNITS\nSITE core7\n SIZE 0.2 BY 1.4 ;\nEND core7\n", + encoding="utf-8", + ) + config = tmp_path / "config" / "dreamplace.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({knob_id.removeprefix("place."): before}), encoding="utf-8") + workspace = SimpleNamespace( + directory=tmp_path, + config={"dreamplace": config}, + pdk=SimpleNamespace(tech=tech, site_core="core7"), + flow=SimpleNamespace(data={"steps": [{"name": "place", "tool": "dreamplace"}]}), + ) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": knob_id, "value": written}], + candidate_id, + ) + return workspace, tmp_path / "analysis" / "candidate_materialization.v1.json" + + +def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> None: + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-1", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + analysis = tmp_path / "analysis" + _write_unknown_runtime_report(analysis) request = SimpleNamespace( candidate_id="candidate-1", target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, ) receipt = _candidate_parameter_receipt( - SimpleNamespace(directory=tmp_path), + workspace, request, ".agent/candidates/candidate-1", materialization, @@ -40,50 +98,80 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No assert receipt_path.is_file() assert json.loads(receipt_path.read_text(encoding="utf-8")) == receipt assert sha256_path(receipt_path) is not None + assert receipt["tool"] == TOOL + materialization_ref = receipt["materialization"] + assert materialization_ref["target_step"] == "place" + assert materialization_ref["config_ref"] == "config/dreamplace.json" + assert materialization_ref["before_snapshot_ref"].endswith("dreamplace.before.json") + assert materialization_ref["after_snapshot_ref"].endswith("dreamplace.after.json") + assert materialization_ref["receipt_sha256"] != materialization_ref["registry_sha256"] def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monkeypatch) -> None: - analysis = tmp_path / "analysis" - analysis.mkdir() - materialization = analysis / "candidate_materialization.v1.json" - materialization.write_text( - json.dumps( - { - "patch": [{"knob_id": "place.cell_padding_x", "value": 200}], - "configs": [{}], - } - ), - encoding="utf-8", + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-padding", + knob_id="place.cell_padding_x", + before=0, + written=1, ) request = SimpleNamespace( candidate_id="candidate-padding", target_step="place", - patch=[{"knob_id": "place.cell_padding_x", "value": 200}], + patch=[{"knob_id": "place.cell_padding_x", "value": 1}], + context_sha256=HASH, ) monkeypatch.setattr( "agent.workspace_api._parameter_receipt_context", lambda *_args: {"site_width_dbu": 200}, ) + _write_unknown_runtime_report(tmp_path / "analysis") receipt = _candidate_parameter_receipt( - SimpleNamespace(directory=tmp_path), + workspace, request, ".agent/candidates/candidate-padding", materialization, parent_flow_sha256="sha256:" + "0" * 64, ) assert receipt["requested"] == {"knob_id": "place.cell_padding_x", "value": 1, "unit": "site"} + assert receipt["materialization"]["written_value"] == 200 + assert receipt["materialization"]["unit"] == "dbu" -def test_candidate_receipt_preserves_native_consumer_observation_and_transition( - tmp_path: Path, -) -> None: +def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> None: analysis = tmp_path / "analysis" analysis.mkdir() materialization = analysis / "candidate_materialization.v1.json" materialization.write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}], "configs": [{}]}), + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), encoding="utf-8", ) + request = SimpleNamespace( + candidate_id="candidate-1", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + ) + + with pytest.raises(CandidateMaterializationError): + _candidate_parameter_receipt( + SimpleNamespace(directory=tmp_path), + request, + ".agent/candidates/candidate-1", + materialization, + ) + + +def test_candidate_receipt_preserves_native_consumer_observation_and_transition( + tmp_path: Path, +) -> None: + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-floor", + knob_id="place.target_density", + before=0.5, + written=0.2, + ) + analysis = tmp_path / "analysis" observation = { "requested_target_density": 0.2, "effective_target_density": 0.8, @@ -104,6 +192,7 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( (analysis / "parameter_runtime_report.v1.json").write_text( json.dumps( { + "tool": TOOL, "application_status": "applied", "effective_initial": {"value": 0.8, "unit": "ratio"}, "effective_final": {"value": 0.8, "unit": "ratio"}, @@ -128,10 +217,11 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( candidate_id="candidate-floor", target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.2}], + context_sha256=HASH, ) receipt = _candidate_parameter_receipt( - SimpleNamespace(directory=tmp_path), + workspace, request, ".agent/candidates/candidate-floor", materialization, @@ -139,3 +229,15 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( assert receipt["consumer_observation"] == observation assert receipt["transitions"] == [transition] + + +def test_parameter_receipt_rejects_unbound_tool_metadata() -> None: + with pytest.raises(ValueError, match="tool metadata"): + build_parameter_application_receipt( + receipt_id="parameter-receipt-1", + tool={"name": "DREAMPlace", "revision": "bound"}, + context={"stage": "place"}, + requested={"knob_id": "place.target_density", "value": 0.85, "unit": "ratio"}, + materialization={}, + runtime_report={"activation": {"status": "unknown", "consumers": []}}, + ) diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 3bd317200..23d37cf3d 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -10,6 +10,8 @@ from chipcompiler.runtime.requests import RequestValidationError from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame +CONTEXT_SHA256 = "sha256:" + "a" * 64 + def test_agent_methods_keep_the_original_rpc_names(): assert agent_method_names() == ( @@ -38,6 +40,7 @@ def test_agent_request_normalizes_camel_case_fields(): "patch": [], "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", + "contextSha256": CONTEXT_SHA256, "parentCandidateRootRef": ".agent/candidates/candidate-0", }, ) @@ -50,10 +53,27 @@ def test_agent_request_normalizes_camel_case_fields(): patch=[], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, parent_candidate_root_ref=".agent/candidates/candidate-0", ) +def test_candidate_rerun_request_requires_context_hash(): + with pytest.raises(RequestValidationError, match="missing required field: context_sha256"): + parse_agent_request_model( + CandidateRerunRequest, + { + "workspaceId": "workspace-1", + "targetStep": "place", + "endStep": "Harden", + "candidateId": "candidate-1", + "patch": [{"knob_id": "place.target_density", "value": 0.6}], + "executionScope": "full_flow", + "idempotencyKey": "episode-1.intervention-1", + }, + ) + + def test_agent_request_rejects_duplicate_aliases(): with pytest.raises(RequestValidationError, match="duplicate field: workspace_id"): parse_agent_request_model( @@ -83,7 +103,7 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): "params": { "workspaceId": "workspace-1", "targetStep": "place", - "endStep": "route", + "endStep": "Harden", "candidateId": "candidate-1", "patch": [ {"knob_id": "place.target_density", "value": 0.6}, @@ -91,6 +111,7 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): ], "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", + "contextSha256": CONTEXT_SHA256, }, } ) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 3e23aa13e..25f1df343 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -6,6 +6,7 @@ import pytest from agent.data.candidate_artifacts import sha256_path +from agent.data.candidate_materialization import materialize_candidate_config from agent.requests import CandidateRerunRequest from agent.workspace_api import ( FlowAgentRuntimeApi, @@ -19,6 +20,8 @@ from chipcompiler.runtime.operations import RuntimeOperationManager from chipcompiler.runtime.workspace_api import RuntimeApiError +CONTEXT_SHA256 = "sha256:" + "a" * 64 + def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): output_dir = tmp_path / "place_dreamplace" / "output" @@ -89,6 +92,7 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( {"name": "Floorplan", "tool": "ecc", "state": "Success"}, {"name": "place", "tool": "dreamplace", "state": "Success"}, {"name": "CTS", "tool": "ecc", "state": "Success"}, + {"name": "Harden", "tool": "ecc", "state": "Success"}, ] } flow_path = tmp_path / "home" / "flow.json" @@ -106,6 +110,7 @@ def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( tmp_path / "place_dreamplace" / "output", tmp_path / "place_dreamplace" / "analysis", tmp_path / "CTS_ecc" / "output", + tmp_path / "Harden_ecc" / "output", ): directory.mkdir(parents=True) (directory / "stale").write_text("stale", encoding="utf-8") @@ -131,6 +136,11 @@ def build_flow(candidate_workspace, *, create_step_workspaces=True): tool="ecc", output={"dir": root / "CTS_ecc" / "output"}, ), + SimpleNamespace( + name="Harden", + tool="ecc", + output={"dir": root / "Harden_ecc" / "output"}, + ), ), ) flows.append(flow) @@ -173,11 +183,12 @@ def materialize(candidate_workspace, target, patch, candidate): CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) @@ -190,11 +201,12 @@ def materialize(candidate_workspace, target, patch, candidate): CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) assert duplicate["operationId"] == result["operationId"] @@ -211,11 +223,12 @@ def materialize(candidate_workspace, target, patch, candidate): ("reapply", "place"), ("init", "place"), ("init", "CTS"), + ("init", "Harden"), ] candidate_root = tmp_path / ".agent" / "candidates" / "candidate-1" candidate_root_ref = ".agent/candidates/candidate-1" candidate_manifest_ref = f"{candidate_root_ref}/analysis/candidate_workspace.v1.json" - assert flows[0].run_calls == [("place", True), ("CTS", True)] + assert flows[0].run_calls == [("place", True), ("CTS", True), ("Harden", True)] assert flows[0].created is True assert flows[0].initialize_config is False assert flow_path.read_bytes() == parent_flow_bytes @@ -223,37 +236,48 @@ def materialize(candidate_workspace, target, patch, candidate): assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() assert (tmp_path / "place_dreamplace" / "analysis" / "stale").is_file() assert (tmp_path / "CTS_ecc" / "output" / "stale").is_file() + assert (tmp_path / "Harden_ecc" / "output" / "stale").is_file() assert (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") == ( '{"target_density": 0.6}\n' ) assert not list((candidate_root / "place_dreamplace" / "output").iterdir()) assert not list((candidate_root / "place_dreamplace" / "analysis").iterdir()) assert not list((candidate_root / "CTS_ecc" / "output").iterdir()) + assert not list((candidate_root / "Harden_ecc" / "output").iterdir()) candidate_manifest = candidate_root / "analysis" / "candidate_workspace.v1.json" result = terminal["result"] assert {key: value for key, value in result.items() if key != "candidateManifestSha256"} == { "candidateId": "candidate-1", "candidateManifestRef": candidate_manifest_ref, "candidateRootRef": candidate_root_ref, - "endStep": "CTS", + "endStep": "Harden", "executionScope": "full_flow", "targetStep": "place", } assert candidate_manifest.is_file() assert result["candidateManifestSha256"] == sha256_path(candidate_manifest) - assert ( - json.loads(candidate_manifest.read_text(encoding="utf-8"))["candidate_id"] == "candidate-1" + first_manifest = json.loads(candidate_manifest.read_text(encoding="utf-8")) + assert first_manifest["candidate_id"] == "candidate-1" + assert first_manifest["terminal_state"] == "succeeded" + assert first_manifest["candidate_state_sha256"].startswith("sha256:") + assert "candidate_execution_receipt" not in first_manifest["artifacts"] + execution_receipt = json.loads( + (candidate_root / "analysis" / "candidate_execution_receipt.v1.json").read_text( + encoding="utf-8" + ) ) + assert execution_receipt["candidate_manifest_sha256"] == result["candidateManifestSha256"] second = api.candidate_rerun( CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-2", patch=[{"knob_id": "place.routability_opt", "value": True}], execution_scope="full_flow", idempotency_key="episode-1.intervention-2", + context_sha256=CONTEXT_SHA256, parent_candidate_root_ref=candidate_root_ref, ) ) @@ -275,6 +299,173 @@ def materialize(candidate_workspace, target, patch, candidate): ).read_text(encoding="utf-8") ) assert second_manifest["parent_candidate_root_ref"] == candidate_root_ref + assert second_manifest["parent_manifest_ref"] == candidate_manifest_ref + assert second_manifest["parent_manifest_sha256"] == sha256_path(candidate_manifest) + assert second_manifest["parent_state_sha256"] == first_manifest["candidate_state_sha256"] + + first_manifest["terminal_state"] = "failed" + candidate_manifest.write_text(json.dumps(first_manifest), encoding="utf-8") + rejected = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-3", + patch=[{"knob_id": "place.target_density", "value": 0.7}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-3", + context_sha256=CONTEXT_SHA256, + parent_candidate_root_ref=candidate_root_ref, + ) + ) + terminal = _wait_for_terminal(api.ecc_api.operations, rejected["operationId"], "failed") + assert "verified successful Harden candidate" in terminal["error"]["message"] + assert not (tmp_path / ".agent" / "candidates" / "candidate-3").exists() + + +def test_failed_candidate_returns_materialization_application_and_manifest_evidence( + monkeypatch, + tmp_path, +) -> None: + candidate = tmp_path / ".agent" / "candidates" / "candidate-failed" + flow_path = candidate / "home" / "flow.json" + flow_path.parent.mkdir(parents=True) + flow_path.write_text( + json.dumps( + { + "steps": [ + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "Harden", "tool": "ecc", "state": "Success"}, + ] + } + ), + encoding="utf-8", + ) + config = candidate / "config" / "dreamplace.json" + config.parent.mkdir(parents=True) + config.write_text('{"target_density": 0.5}', encoding="utf-8") + candidate_workspace = SimpleNamespace( + directory=candidate, + config={"dreamplace": config}, + flow=SimpleNamespace(data=json.loads(flow_path.read_text()), path=flow_path), + ) + parent = { + "root": tmp_path, + "root_ref": None, + "flow_sha256": "sha256:" + "1" * 64, + "state_sha256": "sha256:" + "2" * 64, + "manifest_ref": None, + "manifest_sha256": None, + } + steps = tuple( + SimpleNamespace(name=name, tool=tool, output={}) + for name, tool in (("place", "dreamplace"), ("Harden", "ecc")) + ) + flow = SimpleNamespace( + workspace_steps=steps, + create_step_workspaces=lambda **_kwargs: None, + ) + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + monkeypatch.setattr( + "agent.workspace_api._create_candidate_workspace", + lambda *_args: (candidate_workspace, ".agent/candidates/candidate-failed", parent), + ) + monkeypatch.setattr(api, "_build_flow", lambda *_args, **_kwargs: flow) + monkeypatch.setattr( + "agent.workspace_api._materialize_candidate_rerun", + lambda workspace, _flow, request: materialize_candidate_config( + workspace, request.target_step, request.patch, request.candidate_id + ), + ) + monkeypatch.setattr("agent.workspace_api._prepare_candidate_rerun", lambda *_args: None) + monkeypatch.setattr("agent.workspace_api._reapply_candidate_input", lambda *_args: None) + tool = { + "name": "DREAMPlace", + "revision": "ecc.dreamplace.parameter_runtime_report.v2", + "source_sha256": "sha256:" + "3" * 64, + } + + def run_candidate_step(_flow, step, **_kwargs): + if step.name == "place": + report = { + "tool": tool, + "application_status": "applied", + "effective_initial": {"value": 0.6, "unit": "ratio"}, + "effective_final": {"value": 0.6, "unit": "ratio"}, + "activation": { + "status": "used", + "consumers": [ + { + "consumer_id": "dreamplace.density_objective", + "outcome": "entered", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": "sha256:" + "4" * 64, + } + ], + }, + "consumer_observation": { + "requested_target_density": 0.6, + "effective_target_density": 0.6, + "density_tensor_value": 0.6, + "placement_iteration_count": 3, + "evidence_complete": True, + }, + "transitions": [], + } + (candidate / "analysis" / "parameter_runtime_report.v1.json").write_text( + json.dumps(report), encoding="utf-8" + ) + return + raise RuntimeError("Harden failed") + + monkeypatch.setattr("agent.workspace_api._run_candidate_step", run_candidate_step) + monkeypatch.setattr( + "agent.workspace_api._parameter_receipt_context", + lambda *_args: {"site_width_dbu": 200}, + ) + + started = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-failed", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.failed", + context_sha256=CONTEXT_SHA256, + ) + ) + + terminal = _wait_for_terminal(ecc_api.operations, started["operationId"], "failed") + assert terminal["result"].get("evidenceError") is None, terminal["result"].get("evidenceError") + assert "parameterApplicationReceipt" in terminal["result"], terminal + application = terminal["result"]["parameterApplicationReceipt"] + assert application["application_status"] == "applied" + assert application["tool"] == tool + assert application["context"]["tool_revision"] == tool["revision"] + assert application["context"]["context_sha256"] == CONTEXT_SHA256 + manifest = json.loads( + (candidate / "analysis" / "candidate_workspace.v1.json").read_text(encoding="utf-8") + ) + assert manifest["terminal_state"] == "failed" + assert set(manifest["artifacts"]) >= { + "candidate_materialization", + "parameter_runtime_report", + "parameter_application_receipt", + } + assert "candidate_execution_receipt" not in manifest["artifacts"] + assert terminal["result"]["candidateManifestSha256"] == sha256_path( + candidate / "analysis" / "candidate_workspace.v1.json" + ) + execution_receipt = json.loads( + (candidate / "analysis" / "candidate_execution_receipt.v1.json").read_text(encoding="utf-8") + ) + assert ( + execution_receipt["candidate_manifest_sha256"] + == terminal["result"]["candidateManifestSha256"] + ) def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): @@ -287,7 +478,7 @@ def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(t CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[ {"knob_id": "place.target_density", "value": 0.6}, @@ -295,6 +486,49 @@ def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(t ], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + +def test_candidate_rerun_rejects_invalid_context_hash_before_starting_an_operation(tmp_path): + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="context_sha256"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256="sha256:invalid", + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + +def test_candidate_rerun_rejects_non_harden_end_step_before_starting_an_operation(tmp_path): + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="end step must be Harden"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="CTS", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) @@ -310,11 +544,12 @@ def test_candidate_rerun_rejects_unsafe_candidate_id_before_starting_an_operatio CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="../escape", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) @@ -332,11 +567,12 @@ def test_candidate_rerun_rejects_unsafe_parent_candidate_ref_before_starting_an_ CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, parent_candidate_root_ref="../outside", ) ) @@ -355,11 +591,12 @@ def test_candidate_rerun_rejects_parent_workspace_symlinks(tmp_path): CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) @@ -391,11 +628,12 @@ def fail_copy(*_args, **_kwargs): CandidateRerunRequest( workspace_id="workspace-1", target_step="place", - end_step="CTS", + end_step="Harden", candidate_id="candidate-1", patch=[{"knob_id": "place.target_density", "value": 0.6}], execution_scope="full_flow", idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, ) ) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 03c8e4d46..59a4016dd 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -5,7 +5,7 @@ from hashlib import sha256 from pathlib import Path -from chipcompiler.runtime.operations import RuntimeOperationConflict +from chipcompiler.runtime.operations import RuntimeOperationConflict, RuntimeOperationFailed from chipcompiler.runtime.requests import WorkspaceIdRequest from chipcompiler.runtime.workspace_api import ( RuntimeApiError, @@ -24,6 +24,10 @@ validate_candidate_step_contract, ) from .data.candidate_artifacts import sha256_path, validate_candidate_id, write_json_atomic +from .data.candidate_materialization import ( + candidate_written_patch, + validate_candidate_materialization_receipt, +) from .data.parameter_application_receipt import build_parameter_application_receipt from .engine import AgentEngineFlow from .requests import ( @@ -131,19 +135,15 @@ def candidate_rerun(self, request: CandidateRerunRequest) -> dict: raise RuntimeApiError("command_failed", str(exc)) from exc def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: - ( - candidate_workspace, - candidate_root_ref, - parent_flow_sha256, - parent_state_sha256, - ) = _create_candidate_workspace( + candidate_workspace, candidate_root_ref, parent = _create_candidate_workspace( self.ecc_api, session.workspace, request.candidate_id, request.parent_candidate_root_ref, ) - flow = self._build_flow(candidate_workspace, create_step_workspaces=False) + flow = None try: + flow = self._build_flow(candidate_workspace, create_step_workspaces=False) create_step_workspaces = getattr(flow, "create_step_workspaces", None) if callable(create_step_workspaces): create_step_workspaces(initialize_config=False) @@ -161,42 +161,36 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> _reapply_candidate_input(candidate_workspace, flow, request.target_step) for step in steps: _run_candidate_step(flow, step, observer=observer) - parameter_receipt = None - materialization_path = ( - Path(candidate_workspace.directory) - / "analysis" - / "candidate_materialization.v1.json" + return _candidate_rerun_result( + candidate_workspace, + request, + candidate_root_ref, + parent, + terminal_state="succeeded", ) - if materialization_path.is_file(): - parameter_receipt = _candidate_parameter_receipt( + except Exception as exc: + try: + result = _candidate_rerun_result( candidate_workspace, request, candidate_root_ref, - materialization_path, - parent_flow_sha256, + parent, + terminal_state="failed", ) - result = { - "candidateId": request.candidate_id, - **_candidate_workspace_receipt( - candidate_workspace, - candidate_root_ref, - request.candidate_id, - parent_flow_sha256, - request.parent_candidate_root_ref, - request.target_step, - request.end_step, - request.execution_scope, - parent_state_sha256, - ), - "endStep": request.end_step, - "executionScope": request.execution_scope, - "targetStep": request.target_step, - } - if parameter_receipt is not None: - result["parameterApplicationReceipt"] = parameter_receipt - return result + except Exception as evidence_error: + result = { + "candidateId": request.candidate_id, + "candidateRootRef": candidate_root_ref, + "evidenceError": str(evidence_error), + } + raise RuntimeOperationFailed( + str(exc), + code=getattr(exc, "code", "command_failed"), + result=result, + ) from exc finally: - self.ecc_api._close_transient_flow_db(flow) + if flow is not None: + self.ecc_api._close_transient_flow_db(flow) def _build_flow(self, workspace, *, create_step_workspaces: bool = True): try: @@ -266,6 +260,8 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: validate_candidate_id(request.candidate_id) except ValueError as exc: raise RuntimeApiError("invalid_request", "candidate rerun candidate_id is invalid") from exc + if request.end_step != "Harden": + raise RuntimeApiError("invalid_request", "candidate rerun end step must be Harden") if request.execution_scope != "full_flow": raise RuntimeApiError( "invalid_request", "candidate rerun execution scope must be full_flow" @@ -287,6 +283,11 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: request.idempotency_key ): raise RuntimeApiError("invalid_request", "candidate rerun idempotency key is invalid") + if ( + not isinstance(request.context_sha256, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", request.context_sha256) is None + ): + raise RuntimeApiError("invalid_request", "candidate rerun context_sha256 is invalid") if request.parent_candidate_root_ref is not None: _validate_parent_candidate_root_ref(request.parent_candidate_root_ref) @@ -309,11 +310,10 @@ def _create_candidate_workspace( ecc_api, workspace, candidate_id: str, parent_candidate_root_ref: str | None = None ): workspace_root = _parent_workspace_root(workspace) - source_root = _candidate_parent_root(workspace_root, parent_candidate_root_ref) - _reject_workspace_symlinks(source_root) + _reject_workspace_symlinks(_candidate_parent_root(workspace_root, parent_candidate_root_ref)) + parent = _candidate_parent_binding(workspace_root, parent_candidate_root_ref) + source_root = parent["root"] candidate_root = _candidate_workspace_root(workspace_root, candidate_id) - parent_flow_sha256 = _required_file_sha256(source_root / "home" / "flow.json", "flow") - parent_state_sha256 = _workspace_state_sha256(source_root) candidate_root.parent.mkdir(parents=True, exist_ok=True) try: candidate_root.parent.resolve().relative_to(workspace_root) @@ -334,8 +334,7 @@ def _create_candidate_workspace( return ( candidate_workspace, candidate_root.relative_to(workspace_root).as_posix(), - parent_flow_sha256, - parent_state_sha256, + parent, ) @@ -346,6 +345,7 @@ def _workspace_state_sha256(root: Path) -> str: "config/floorplan_ecc.json", "config/fixfanout_ecc.json", "config/dreamplace_ecc.json", + "config/dreamplace.json", ) hashes = { relative: _required_file_sha256(root / relative, relative) @@ -393,6 +393,51 @@ def _candidate_parent_root(workspace_root: Path, candidate_root_ref: str | None) return resolved +def _candidate_parent_binding(workspace_root: Path, candidate_root_ref: str | None) -> dict: + source = _candidate_parent_root(workspace_root, candidate_root_ref) + flow_sha256 = _required_file_sha256(source / "home" / "flow.json", "parent flow") + state_sha256 = _workspace_state_sha256(source) + binding = { + "root": source, + "root_ref": candidate_root_ref, + "flow_sha256": flow_sha256, + "state_sha256": state_sha256, + "manifest_ref": None, + "manifest_sha256": None, + } + if candidate_root_ref is None: + return binding + manifest_ref = f"{candidate_root_ref}/analysis/{_CANDIDATE_WORKSPACE_MANIFEST}" + manifest_path = workspace_root / manifest_ref + manifest_sha256 = _required_file_sha256(manifest_path, "parent manifest") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeApiError("command_failed", "candidate parent manifest is invalid") from exc + expected = { + "schema": _CANDIDATE_WORKSPACE_SCHEMA, + "schema_version": 1, + "candidate_id": Path(candidate_root_ref).name, + "candidate_root_ref": candidate_root_ref, + "candidate_flow_sha256": flow_sha256, + "candidate_state_sha256": state_sha256, + "terminal_state": "succeeded", + "end_step": "Harden", + "execution_scope": "full_flow", + } + if not isinstance(manifest, dict) or any( + manifest.get(key) != value for key, value in expected.items() + ): + raise RuntimeApiError( + "command_failed", "candidate parent is not a verified successful Harden candidate" + ) + return { + **binding, + "manifest_ref": manifest_ref, + "manifest_sha256": manifest_sha256, + } + + def _reject_workspace_symlinks(workspace_root: Path) -> None: for directory, directories, files in os.walk(workspace_root, followlinks=False): for name in directories + files: @@ -430,27 +475,31 @@ def _candidate_workspace_receipt( workspace, candidate_root_ref: str, candidate_id: str, - parent_flow_sha256: str, - parent_candidate_root_ref: str | None, + parent: dict, target_step: str, end_step: str, execution_scope: str, - parent_state_sha256: str, + terminal_state: str, ) -> dict: candidate_root = Path(workspace.directory).resolve() manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST if manifest_path.parent.is_symlink(): raise RuntimeApiError("command_failed", "candidate manifest path is unsafe") candidate_flow_sha256 = _required_file_sha256(candidate_root / "home" / "flow.json", "flow") + candidate_state_sha256 = _workspace_state_sha256(candidate_root) manifest = { "schema": _CANDIDATE_WORKSPACE_SCHEMA, "schema_version": 1, "candidate_id": candidate_id, "candidate_root_ref": candidate_root_ref, - "parent_candidate_root_ref": parent_candidate_root_ref, - "parent_flow_sha256": parent_flow_sha256, - "parent_state_sha256": parent_state_sha256, + "parent_candidate_root_ref": parent["root_ref"], + "parent_manifest_ref": parent["manifest_ref"], + "parent_manifest_sha256": parent["manifest_sha256"], + "parent_flow_sha256": parent["flow_sha256"], + "parent_state_sha256": parent["state_sha256"], "candidate_flow_sha256": candidate_flow_sha256, + "candidate_state_sha256": candidate_state_sha256, + "terminal_state": terminal_state, "target_step": target_step, "end_step": end_step, "execution_scope": execution_scope, @@ -468,20 +517,27 @@ def _candidate_workspace_receipt( "ref": relative, "sha256": _required_file_sha256(artifact, key), } - if artifacts: - manifest["artifacts"] = artifacts + manifest["artifacts"] = artifacts + try: + write_json_atomic(manifest_path, manifest) + except OSError as exc: + raise RuntimeApiError("command_failed", f"candidate manifest write failed: {exc}") from exc + manifest_sha256 = _required_file_sha256(manifest_path, "manifest") replay_path = candidate_root / "analysis" / "candidate_execution_receipt.v1.json" replay = { "schema": "ecc.candidate_execution_receipt.v1", "candidate_id": candidate_id, "candidate_root_ref": candidate_root_ref, - "parent_candidate_root_ref": parent_candidate_root_ref, - "parent_flow_sha256": parent_flow_sha256, - "parent_state_sha256": parent_state_sha256, + "parent_candidate_root_ref": parent["root_ref"], + "parent_manifest_ref": parent["manifest_ref"], + "parent_manifest_sha256": parent["manifest_sha256"], + "parent_flow_sha256": parent["flow_sha256"], + "parent_state_sha256": parent["state_sha256"], + "terminal_state": terminal_state, "target_step": target_step, "end_step": end_step, "execution_scope": execution_scope, - "candidate_manifest_sha256": None, + "candidate_manifest_sha256": manifest_sha256, } try: write_json_atomic(replay_path, replay) @@ -489,15 +545,6 @@ def _candidate_workspace_receipt( raise RuntimeApiError( "command_failed", f"candidate replay receipt write failed: {exc}" ) from exc - artifacts["candidate_execution_receipt"] = { - "ref": "analysis/candidate_execution_receipt.v1.json", - "sha256": _required_file_sha256(replay_path, "candidate replay receipt"), - } - try: - write_json_atomic(manifest_path, manifest) - except OSError as exc: - raise RuntimeApiError("command_failed", f"candidate manifest write failed: {exc}") from exc - manifest_sha256 = _required_file_sha256(manifest_path, "manifest") return { "candidateRootRef": candidate_root_ref, "candidateManifestRef": f"{candidate_root_ref}/analysis/{_CANDIDATE_WORKSPACE_MANIFEST}", @@ -505,33 +552,81 @@ def _candidate_workspace_receipt( } +def _candidate_rerun_result( + workspace, + request, + candidate_root_ref: str, + parent: dict, + *, + terminal_state: str, +) -> dict: + materialization_path = ( + Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" + ) + parameter_receipt = None + evidence_error = None + if materialization_path.is_file(): + try: + parameter_receipt = _candidate_parameter_receipt( + workspace, + request, + candidate_root_ref, + materialization_path, + parent["flow_sha256"], + parent, + ) + except Exception as exc: + if terminal_state == "succeeded": + raise + evidence_error = str(exc) + result = { + "candidateId": request.candidate_id, + **_candidate_workspace_receipt( + workspace, + candidate_root_ref, + request.candidate_id, + parent, + request.target_step, + request.end_step, + request.execution_scope, + terminal_state, + ), + "endStep": request.end_step, + "executionScope": request.execution_scope, + "targetStep": request.target_step, + } + if parameter_receipt is not None: + result["parameterApplicationReceipt"] = parameter_receipt + if evidence_error is not None: + result["evidenceError"] = evidence_error + return result + + def _candidate_parameter_receipt( workspace, request, candidate_root_ref: str, materialization_path: Path, parent_flow_sha256: str | None = None, + parent: dict | None = None, ) -> dict: - materialization = json.loads(materialization_path.read_text(encoding="utf-8")) - configs = materialization.get("configs") or [{}] - config = configs[0] + expected_path = Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" + if materialization_path.resolve() != expected_path.resolve(): + raise RuntimeApiError("command_failed", "candidate materialization path is invalid") + materialization = validate_candidate_materialization_receipt(workspace, request.target_step) + if materialization is None: + raise RuntimeApiError("command_failed", "candidate materialization receipt is missing") patch = request.patch[0] + if materialization["candidate_id"] != request.candidate_id or materialization[ + "patch" + ] != candidate_written_patch(workspace, request.target_step, request.patch): + raise RuntimeApiError( + "command_failed", "candidate materialization request binding is invalid" + ) + config = materialization["configs"][0] + snapshot = materialization["snapshots"][0] knob_id = patch["knob_id"] unit = _parameter_unit(knob_id) - h = sha256(materialization_path.read_bytes()).hexdigest() - digest = f"sha256:{h}" - runtime_report_path = ( - Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" - ) - try: - runtime_report = json.loads(runtime_report_path.read_text(encoding="utf-8")) - except (OSError, ValueError): - runtime_report = { - "application_status": "unknown", - "activation": {"status": "unknown", "consumers": []}, - "effective_initial": {"value": None, "unit": unit}, - "effective_final": {"value": None, "unit": unit}, - } tool_name = ( "ECC-Floorplan" if knob_id.startswith("floorplan.") @@ -539,6 +634,24 @@ def _candidate_parameter_receipt( if knob_id == "synth.max_fanout" else "DREAMPlace" ) + runtime_report_path = ( + Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" + ) + try: + runtime_report = json.loads(runtime_report_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeApiError("command_failed", "candidate runtime report is unavailable") from exc + runtime_tool = runtime_report.get("tool") if isinstance(runtime_report, dict) else None + if ( + not isinstance(runtime_tool, dict) + or runtime_tool.get("name") != tool_name + or not isinstance(runtime_tool.get("revision"), str) + or not runtime_tool["revision"].strip() + or not isinstance(runtime_tool.get("source_sha256"), str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", runtime_tool["source_sha256"]) is None + ): + raise RuntimeApiError("command_failed", "candidate runtime report tool binding is invalid") + tool = {key: runtime_tool[key] for key in ("name", "revision", "source_sha256")} receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" context = ( _parameter_receipt_context(workspace, request, parent_flow_sha256) @@ -549,28 +662,39 @@ def _candidate_parameter_receipt( "lattice_version": "ecos.optimization_lattice.v1", } ) + context["tool_revision"] = tool["revision"] + context["context_sha256"] = request.context_sha256 requested_value = patch["value"] + written_unit = unit if knob_id == "place.cell_padding_x": - site_width = context.get("site_width_dbu") - if type(site_width) is not int or site_width <= 0 or requested_value % site_width: - raise RuntimeApiError("command_failed", "cell padding surface unit is unavailable") - requested_value //= site_width + written_unit = "dbu" + parent = parent or {} return build_parameter_application_receipt( receipt_id=f"parameter-receipt-{request.candidate_id}", - tool={"name": tool_name, "revision": "bound"}, + tool=tool, context=context, requested={"knob_id": knob_id, "value": requested_value, "unit": unit}, materialization={ "receipt_ref": "analysis/candidate_materialization.v1.json", - "receipt_sha256": materialization.get("receipt_sha256", digest), - "registry_sha256": materialization.get("registry_sha256", digest), - "patch_sha256": materialization.get("patch_sha256", digest), + "receipt_sha256": materialization["receipt_sha256"], + "registry_sha256": materialization["registry_sha256"], + "patch_sha256": materialization["patch_sha256"], "candidate_ref": candidate_root_ref, + "target_step": request.target_step, "workspace_ref": candidate_root_ref, - "config_before_sha256": config.get("before_sha256", digest), - "config_after_sha256": config.get("after_sha256", digest), - "written_value": patch["value"], - "unit": unit, + "config_ref": config["ref"], + "config_before_sha256": config["before_sha256"], + "config_after_sha256": config["after_sha256"], + "before_snapshot_ref": snapshot["before_ref"], + "before_snapshot_sha256": snapshot["before_sha256"], + "after_snapshot_ref": snapshot["after_ref"], + "after_snapshot_sha256": snapshot["after_sha256"], + "parent_ref": parent.get("root_ref"), + "parent_manifest_ref": parent.get("manifest_ref"), + "parent_manifest_sha256": parent.get("manifest_sha256"), + "parent_state_sha256": parent.get("state_sha256"), + "written_value": materialization["patch"][0]["value"], + "unit": written_unit, }, runtime_report=runtime_report, destination=receipt_path, @@ -638,7 +762,6 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d "parent_lineage_sha256": parent_flow_sha256, "seed": 0, "site_width_dbu": site_width_dbu, - "tool_revision": "bound", "unit": unit, } return context diff --git a/chipcompiler/runtime/operations.py b/chipcompiler/runtime/operations.py index eed9292a2..88c5b8765 100644 --- a/chipcompiler/runtime/operations.py +++ b/chipcompiler/runtime/operations.py @@ -38,6 +38,21 @@ class RuntimeOperationCancelled(RuntimeError): """Cancellation was accepted at a safe step boundary.""" +class RuntimeOperationFailed(RuntimeError): + """A failed operation with an auditable partial result.""" + + def __init__( + self, + message: str, + *, + result: dict[str, Any], + code: str = "command_failed", + ) -> None: + super().__init__(message) + self.code = code + self.result = result + + @dataclass class RuntimeOperation: operation_id: str @@ -276,10 +291,10 @@ def _run( result = runner(observer) with self._lock: operation = self._operations[operation_id] + operation.result = result if operation.cancel_requested: raise RuntimeOperationCancelled("operation cancelled at a step boundary") operation.state = "succeeded" - operation.result = result operation.updated_at = time.time() event = self._new_event_locked( operation, @@ -303,7 +318,33 @@ def _run( event = self._new_event_locked( operation, event_type, - {"error": operation.error}, + { + "error": operation.error, + **( + {"result": operation.result} if operation.result is not None else {} + ), + }, + ) + except RuntimeOperationFailed as exc: + with self._lock: + operation = self._operations[operation_id] + operation.result = exc.result + if operation.cancel_requested and operation.error is None: + operation.state = "cancelled" + operation.error = {"message": str(exc), "code": "cancelled"} + event_type = "operation.cancelled" + else: + operation.state = "failed" + operation.error = operation.error or { + "message": str(exc), + "code": exc.code, + } + event_type = "operation.failed" + operation.updated_at = time.time() + event = self._new_event_locked( + operation, + event_type, + {"error": operation.error, "result": operation.result}, ) except Exception as exc: with self._lock: diff --git a/test/runtime/test_operations.py b/test/runtime/test_operations.py index 70c9e77e6..f8b95c76d 100644 --- a/test/runtime/test_operations.py +++ b/test/runtime/test_operations.py @@ -3,7 +3,60 @@ from chipcompiler.data import StateEnum from chipcompiler.runtime import operations -from chipcompiler.runtime.operations import RuntimeOperationManager +from chipcompiler.runtime.operations import RuntimeOperationFailed, RuntimeOperationManager + + +def test_structured_failure_preserves_partial_result() -> None: + events = [] + manager = RuntimeOperationManager(events.append) + partial = {"candidateRootRef": ".agent/candidates/candidate-1"} + + def runner(_observer): + raise RuntimeOperationFailed("candidate Harden failed", result=partial) + + started = manager.start( + workspace_id="workspace-1", + kind="candidate_rerun", + origin="agent", + rerun=True, + step="place", + idempotency_key="failed-candidate", + runner=runner, + ) + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "failed" + assert status["result"] == partial + failed = _wait_for_event(events, "operation.failed") + assert failed["payload"]["result"] == partial + + +def test_cancelled_operation_preserves_runner_result() -> None: + entered = threading.Event() + release = threading.Event() + manager = RuntimeOperationManager() + + def runner(_observer): + entered.set() + assert release.wait(timeout=1) + return {"candidateRootRef": ".agent/candidates/candidate-1"} + + started = manager.start( + workspace_id="workspace-1", + kind="candidate_rerun", + origin="agent", + rerun=True, + step="place", + idempotency_key="cancelled-candidate", + runner=runner, + ) + assert entered.wait(timeout=1) + assert manager.request_cancel(started["operationId"])["accepted"] is True + release.set() + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "cancelled" + assert status["result"] == {"candidateRootRef": ".agent/candidates/candidate-1"} def test_successful_step_waits_for_matching_render_ack_before_completing(): From 2d0bbc74e93d2eb432ce78a19baf8310f82620ea Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 28 Aug 2026 19:06:16 +0800 Subject: [PATCH 46/90] fix: complete native parameter runtime evidence --- chipcompiler/tools/ecc/runner.py | 9 ++ chipcompiler/tools/ecc_dreamplace/module.py | 128 ++++++++++++++++-- .../ecc/test_floorplan_runtime_report.py | 7 + .../test_parameter_runtime_report.py | 80 ++++++++++- 4 files changed, 207 insertions(+), 17 deletions(-) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 139df7505..33f0e4878 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -49,6 +49,10 @@ ) +def _runner_source_sha256() -> str: + return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + def temperature_token(temperature) -> str: try: numeric = float(temperature) @@ -823,6 +827,11 @@ def _write_floorplan_parameter_runtime_report( ).hexdigest() ) report = { + "tool": { + "name": "ECC-Floorplan", + "revision": "ecc.floorplan.parameter_runtime_report.v2", + "source_sha256": _runner_source_sha256(), + }, "application_status": "applied" if matches_request else "unknown", "effective_initial": {"value": value, "unit": "ratio"}, "effective_final": {"value": value, "unit": "ratio"}, diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 75aaeb48a..a64f861d2 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -4,7 +4,6 @@ import json import logging import os -import re import sys from contextlib import contextmanager, suppress from pathlib import Path @@ -19,6 +18,7 @@ StepEnum.TIMING_OPT.value, } ) +DREAMPLACE_RUNTIME_REPORT_REVISION = "ecc.dreamplace.parameter_runtime_report.v2" class DreamplaceModule: @@ -118,7 +118,9 @@ def _run(self, *, legalize_only: bool) -> bool: engine = PlacementEngine(params) engine.setup_rawdb(ecc_module=self.ecc_module) - ppa = engine.run() + with _capture_native_runtime() as native_runtime_probe: + ppa = engine.run() + engine.native_runtime_probe = native_runtime_probe if ppa.get("hpwl") == float("inf"): if not legalize_only: @@ -200,6 +202,11 @@ def _write_parameter_runtime_report( "evidence_sha256": _payload_sha256(evidence_payload), } report = { + "tool": { + "name": "DREAMPlace", + "revision": DREAMPLACE_RUNTIME_REPORT_REVISION, + "source_sha256": _source_sha256(), + }, "application_status": "applied" if value is not None else "unknown", "effective_initial": {"value": value, "unit": _runtime_unit(knob_id)}, "effective_final": {"value": value, "unit": _runtime_unit(knob_id)}, @@ -254,12 +261,19 @@ def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> "evidence_complete": valid_iterations and tensor_value == effective, } if knob_id == "place.target_overflow": + overflows = _native_overflow_values(engine) + threshold = _scalar_value(getattr(params, "stop_overflow", None)) + minimum = min(overflows) if overflows else None return { - "effective_stop_overflow": _scalar_value(getattr(params, "stop_overflow", None)), + "effective_stop_overflow": threshold, "final_overflow": _scalar_value(ppa.get("overflow")), "placement_iteration_count": iterations, - "evidence_complete": valid_iterations - and _scalar_value(ppa.get("overflow")) is not None, + "comparison_count": len(overflows), + "minimum_observed_overflow": minimum, + "threshold_reached": minimum <= threshold + if minimum is not None and threshold is not None + else None, + "evidence_complete": valid_iterations and bool(overflows) and threshold is not None, } if knob_id == "place.cell_padding_x": placedb = getattr(engine, "placedb", None) @@ -275,14 +289,26 @@ def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> and type(movable) is int, } if knob_id == "place.density_weight": + probe = _native_runtime_probe(engine) + initializations = probe.get("density_weight_initializations", []) + updates = probe.get("density_weight_updates", []) + initial = initializations[0] if initializations else None + final = ( + updates[-1]["after"] if updates else initializations[-1] if initializations else None + ) return { "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), + "internal_initial_density_weight": initial, + "density_weight_updates": updates, + "density_weight_update_count": len(updates), + "final_internal_density_weight": final, "final_objective": _scalar_value(ppa.get("objective")), "placement_iteration_count": iterations, "evidence_complete": valid_iterations + and initial is not None and _scalar_value(ppa.get("objective")) is not None, } - rounds = _routability_branch_round_count(workspace) + rounds = _native_runtime_probe(engine).get("routability_branch_round_count") return {"branch_round_count": rounds, "evidence_complete": isinstance(rounds, int)} @@ -341,11 +367,87 @@ def _payload_sha256(payload: dict) -> str: return "sha256:" + hashlib.sha256(encoded).hexdigest() -def _routability_branch_round_count(workspace: Workspace) -> int | None: - """Count native routability rounds emitted by the placement engine.""" - log_path = Path(workspace.directory) / "place_dreamplace" / "log" / "place.log" +def _source_sha256() -> str: + return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def _native_runtime_probe(engine) -> dict: + probe = getattr(engine, "native_runtime_probe", None) + return probe if isinstance(probe, dict) else {} + + +def _native_overflow_values(engine) -> list[float]: + metrics = getattr(engine, "metrics", None) + values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] + return [value for item in values if (value := _scalar_value(item)) is not None] + + +def _native_numeric(value): + for operation in ("detach", "cpu", "tolist"): + with suppress(AttributeError): + value = getattr(value, operation)() + if type(value) in {int, float}: + return value + if isinstance(value, list) and value and all(type(item) in {int, float} for item in value): + return value + return None + + +@contextmanager +def _capture_native_runtime(): + from dreamplace.PlaceObj import PlaceObj + + probe = { + "density_weight_initializations": [], + "density_weight_updates": [], + "routability_branch_round_count": 0, + } + original_init = PlaceObj.__init__ + + def observed_init(model, *args, **kwargs): + original_init(model, *args, **kwargs) + _observe_native_model(model, probe) + + PlaceObj.__init__ = observed_init try: - text = log_path.read_text(encoding="utf-8", errors="replace") - except OSError: - return None - return len(re.findall(r"routability optimization round \d+:", text)) + yield probe + finally: + PlaceObj.__init__ = original_init + + +def _observe_native_model(model, probe: dict) -> None: + initialize = model.initialize_density_weight + + def observed_initialize(*args, **kwargs): + result = initialize(*args, **kwargs) + if (value := _native_numeric(result)) is not None: + probe["density_weight_initializations"].append(value) + return result + + model.initialize_density_weight = observed_initialize + operations = model.op_collections + update = getattr(operations, "update_density_weight_op", None) + if callable(update): + + def observed_update(*args, **kwargs): + before = _native_numeric(model.density_weight) + result = update(*args, **kwargs) + after = _native_numeric(model.density_weight) + probe["density_weight_updates"].append( + { + "sequence": len(probe["density_weight_updates"]), + "before": before, + "after": after, + } + ) + return result + + operations.update_density_weight_op = observed_update + adjust_area = getattr(operations, "adjust_node_area_op", None) + if callable(adjust_area): + + def observed_adjust_area(*args, **kwargs): + probe["routability_branch_round_count"] += 1 + return adjust_area(*args, **kwargs) + + operations.adjust_node_area_op = observed_adjust_area diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py index c374e5842..552f15042 100644 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from types import SimpleNamespace @@ -32,6 +33,12 @@ def test_runtime_report_records_native_core_utilization_consumer(tmp_path): _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + source_path = Path(_write_floorplan_parameter_runtime_report.__code__.co_filename) + assert report["tool"] == { + "name": "ECC-Floorplan", + "revision": "ecc.floorplan.parameter_runtime_report.v2", + "source_sha256": "sha256:" + hashlib.sha256(source_path.read_bytes()).hexdigest(), + } assert report["activation"]["status"] == "used" assert report["activation"]["consumers"][0]["consumer_id"] == ( "ifp.die_builder.die_utilization" diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index c252aaafa..48ffde664 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -3,7 +3,10 @@ import json from types import SimpleNamespace -from chipcompiler.tools.ecc_dreamplace.module import _write_parameter_runtime_report +from chipcompiler.tools.ecc_dreamplace.module import ( + _observe_native_model, + _write_parameter_runtime_report, +) class _Scalar: @@ -114,7 +117,12 @@ def test_runtime_report_uses_objective_weight_unit(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(density_weight=0.001), - engine=SimpleNamespace(), + engine=SimpleNamespace( + native_runtime_probe={ + "density_weight_initializations": [0.004], + "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], + } + ), ppa={"iteration": 5, "objective": 12.5}, engine_succeeded=True, ) @@ -123,8 +131,12 @@ def test_runtime_report_uses_objective_weight_unit(tmp_path): assert report["effective_final"]["unit"] == "objective_weight" assert report["consumer_observation"] == { "configured_density_weight": 0.001, + "density_weight_update_count": 1, + "density_weight_updates": [{"after": 0.006, "before": 0.004, "sequence": 0}], "evidence_complete": True, "final_objective": 12.5, + "final_internal_density_weight": 0.006, + "internal_initial_density_weight": 0.004, "placement_iteration_count": 5, } @@ -140,7 +152,7 @@ def test_runtime_report_records_overflow_predicate_evaluation(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(stop_overflow=0.1), - engine=SimpleNamespace(), + engine=SimpleNamespace(metrics={"overflow": [0.7, _Scalar(0.12), 0.08]}), ppa={"iteration": 7, "overflow": 0.08}, engine_succeeded=True, ) @@ -149,10 +161,13 @@ def test_runtime_report_records_overflow_predicate_evaluation(tmp_path): assert report["activation"]["status"] == "used" assert report["activation"]["consumers"][0]["outcome"] == "evaluated" assert report["consumer_observation"] == { + "comparison_count": 3, "effective_stop_overflow": 0.1, "evidence_complete": True, "final_overflow": 0.08, + "minimum_observed_overflow": 0.08, "placement_iteration_count": 7, + "threshold_reached": True, } @@ -228,7 +243,7 @@ def test_runtime_report_requires_a_native_routability_round(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(routability_opt_flag=True), - engine=SimpleNamespace(), + engine=SimpleNamespace(native_runtime_probe={"routability_branch_round_count": 1}), ppa={"iteration": 3}, engine_succeeded=True, ) @@ -237,6 +252,63 @@ def test_runtime_report_requires_a_native_routability_round(tmp_path): assert report["consumer_observation"]["branch_round_count"] == 1 +def test_runtime_report_binds_producer_revision_and_source(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + encoding="utf-8", + ) + + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(target_density=0.85), + engine=_engine(target_density=0.85), + ppa={"iteration": 2}, + engine_succeeded=True, + ) + + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["tool"]["name"] == "DREAMPlace" + assert report["tool"]["revision"] == "ecc.dreamplace.parameter_runtime_report.v2" + assert report["tool"]["source_sha256"].startswith("sha256:") + assert len(report["tool"]["source_sha256"]) == 71 + + +def test_native_probe_observes_density_updates_and_routability_calls(): + probe = { + "density_weight_initializations": [], + "density_weight_updates": [], + "routability_branch_round_count": 0, + } + model = SimpleNamespace(density_weight=0.0) + + def initialize_density_weight(): + model.density_weight = 0.004 + return model.density_weight + + def update_density_weight(): + model.density_weight = 0.006 + return "updated" + + model.initialize_density_weight = initialize_density_weight + model.op_collections = SimpleNamespace( + update_density_weight_op=update_density_weight, + adjust_node_area_op=lambda: "adjusted", + ) + + _observe_native_model(model, probe) + + assert model.initialize_density_weight() == 0.004 + assert model.op_collections.update_density_weight_op() == "updated" + assert model.op_collections.adjust_node_area_op() == "adjusted" + assert probe == { + "density_weight_initializations": [0.004], + "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], + "routability_branch_round_count": 1, + } + + def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() From 4d613810e1c4ecb8635df076e7612287327fbed9 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 29 Aug 2026 09:55:57 +0800 Subject: [PATCH 47/90] chore: ignore generated checklist output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b5b16f46b..fc6f7c0d1 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ result # Generated from uv.lock, not committed requirements_lock.txt +/checklist.json chipcompiler/tools/ecc_dreamplace/dreamplace From e56f0fa16b9b8a0399cd64d385b54b4db570b2d5 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 29 Aug 2026 15:33:38 +0800 Subject: [PATCH 48/90] fix: strengthen parameter evidence binding --- agent/requests.py | 1 + .../test/test_parameter_receipt_artifacts.py | 161 +++++++++++++++++- agent/test/test_requests.py | 20 +++ agent/test/test_workspace_api.py | 51 ++++++ agent/workspace_api.py | 83 +++++++-- chipcompiler/tools/ecc/runner.py | 18 +- chipcompiler/tools/ecc_dreamplace/module.py | 8 +- .../ecc/test_floorplan_runtime_report.py | 54 +++++- .../test_parameter_runtime_report.py | 25 ++- 9 files changed, 392 insertions(+), 29 deletions(-) diff --git a/agent/requests.py b/agent/requests.py index 237b61b2c..1522ae54c 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -35,6 +35,7 @@ class CandidateRerunRequest: execution_scope: str idempotency_key: str context_sha256: str + seed: int parent_candidate_root_ref: str | None = None diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 5b36b2655..6d26241fe 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -13,6 +13,7 @@ ) from agent.data.parameter_application_receipt import build_parameter_application_receipt from agent.workspace_api import _candidate_parameter_receipt +from chipcompiler.runtime.workspace_api import RuntimeApiError HASH = "sha256:" + "a" * 64 PRODUCER = Path(__file__).parents[2] / "chipcompiler/tools/ecc_dreamplace/module.py" @@ -23,10 +24,12 @@ } -def _write_unknown_runtime_report(analysis: Path) -> None: +def _write_unknown_runtime_report(analysis: Path, *, knob_id: str, requested_value: object) -> None: (analysis / "parameter_runtime_report.v1.json").write_text( json.dumps( { + "knob_id": knob_id, + "requested_value": requested_value, "tool": TOOL, "application_status": "unknown", "activation": {"status": "unknown", "consumers": []}, @@ -46,12 +49,23 @@ def _materialized_workspace( before: object, written: object, ) -> tuple[SimpleNamespace, Path]: - tech = tmp_path / "pdk" / "tech.lef" + tech = tmp_path / "pdk" / "prtech" / "techLEF" / "N551P6M_ecos.lef" tech.parent.mkdir(parents=True) tech.write_text( "UNITS\n DATABASE MICRONS 1000 ;\nEND UNITS\nSITE core7\n SIZE 0.2 BY 1.4 ;\nEND core7\n", encoding="utf-8", ) + origin = tmp_path / "origin" + (origin / "rtl").mkdir(parents=True) + (origin / "rtl" / "top.v").write_text("module top; endmodule\n", encoding="utf-8") + (origin / "constraints.sdc").write_text("create_clock clk\n", encoding="utf-8") + (origin / "filelist.f").write_text("rtl/top.v\n", encoding="utf-8") + home = tmp_path / "home" + home.mkdir() + (home / "parameters.json").write_text( + json.dumps({"PDK Root": str(tmp_path / "pdk")}), + encoding="utf-8", + ) config = tmp_path / "config" / "dreamplace.json" config.parent.mkdir(parents=True) config.write_text(json.dumps({knob_id.removeprefix("place."): before}), encoding="utf-8") @@ -79,12 +93,17 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No written=0.85, ) analysis = tmp_path / "analysis" - _write_unknown_runtime_report(analysis) + _write_unknown_runtime_report( + analysis, + knob_id="place.target_density", + requested_value=0.85, + ) request = SimpleNamespace( candidate_id="candidate-1", target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + seed=17, ) receipt = _candidate_parameter_receipt( @@ -92,6 +111,7 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No request, ".agent/candidates/candidate-1", materialization, + parent_flow_sha256=HASH, ) receipt_path = analysis / "parameter_application_receipt.v1.json" @@ -120,12 +140,17 @@ def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monke target_step="place", patch=[{"knob_id": "place.cell_padding_x", "value": 1}], context_sha256=HASH, + seed=17, ) monkeypatch.setattr( "agent.workspace_api._parameter_receipt_context", lambda *_args: {"site_width_dbu": 200}, ) - _write_unknown_runtime_report(tmp_path / "analysis") + _write_unknown_runtime_report( + tmp_path / "analysis", + knob_id="place.cell_padding_x", + requested_value=200, + ) receipt = _candidate_parameter_receipt( workspace, request, @@ -150,6 +175,8 @@ def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> No candidate_id="candidate-1", target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + seed=17, ) with pytest.raises(CandidateMaterializationError): @@ -158,6 +185,7 @@ def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> No request, ".agent/candidates/candidate-1", materialization, + parent_flow_sha256=HASH, ) @@ -192,6 +220,8 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( (analysis / "parameter_runtime_report.v1.json").write_text( json.dumps( { + "knob_id": "place.target_density", + "requested_value": 0.2, "tool": TOOL, "application_status": "applied", "effective_initial": {"value": 0.8, "unit": "ratio"}, @@ -218,6 +248,7 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.2}], context_sha256=HASH, + seed=17, ) receipt = _candidate_parameter_receipt( @@ -225,12 +256,134 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( request, ".agent/candidates/candidate-floor", materialization, + parent_flow_sha256=HASH, ) assert receipt["consumer_observation"] == observation assert receipt["transitions"] == [transition] +def test_candidate_parameter_receipt_rejects_runtime_report_for_another_knob( + tmp_path: Path, +) -> None: + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-density", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + (tmp_path / "analysis" / "parameter_runtime_report.v1.json").write_text( + json.dumps( + { + "knob_id": "place.density_weight", + "requested_value": 0.001, + "tool": TOOL, + "application_status": "applied", + "effective_initial": {"value": 0.001, "unit": "objective_weight"}, + "effective_final": {"value": 0.001, "unit": "objective_weight"}, + "activation": { + "status": "used", + "consumers": [ + { + "consumer_id": "dreamplace.density_preconditioner", + "outcome": "entered", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": HASH, + } + ], + }, + "consumer_observation": {"evidence_complete": True}, + "transitions": [], + } + ), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-density", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + seed=17, + ) + + with pytest.raises(RuntimeApiError, match="runtime report"): + _candidate_parameter_receipt( + workspace, + request, + ".agent/candidates/candidate-density", + materialization, + parent_flow_sha256=HASH, + ) + + +def test_candidate_parameter_receipt_requires_parent_flow_sha256( + tmp_path: Path, +) -> None: + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-no-parent", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + _write_unknown_runtime_report( + tmp_path / "analysis", + knob_id="place.target_density", + requested_value=0.85, + ) + request = SimpleNamespace( + candidate_id="candidate-no-parent", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + seed=17, + ) + + with pytest.raises(RuntimeApiError, match="parent flow"): + _candidate_parameter_receipt( + workspace, + request, + ".agent/candidates/candidate-no-parent", + materialization, + ) + + +def test_candidate_parameter_receipt_rejects_stripped_unknown_ecc_revision( + tmp_path: Path, + monkeypatch, +) -> None: + workspace, materialization = _materialized_workspace( + tmp_path, + candidate_id="candidate-unknown-revision", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + _write_unknown_runtime_report( + tmp_path / "analysis", + knob_id="place.target_density", + requested_value=0.85, + ) + request = SimpleNamespace( + candidate_id="candidate-unknown-revision", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + seed=17, + ) + monkeypatch.setattr("agent.workspace_api.chipcompiler.__version__", " unknown ") + + with pytest.raises(RuntimeApiError, match="ECC revision"): + _candidate_parameter_receipt( + workspace, + request, + ".agent/candidates/candidate-unknown-revision", + materialization, + parent_flow_sha256=HASH, + ) + + def test_parameter_receipt_rejects_unbound_tool_metadata() -> None: with pytest.raises(ValueError, match="tool metadata"): build_parameter_application_receipt( diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 23d37cf3d..04176a3d3 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -41,6 +41,7 @@ def test_agent_request_normalizes_camel_case_fields(): "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", "contextSha256": CONTEXT_SHA256, + "seed": 17, "parentCandidateRootRef": ".agent/candidates/candidate-0", }, ) @@ -54,6 +55,7 @@ def test_agent_request_normalizes_camel_case_fields(): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, parent_candidate_root_ref=".agent/candidates/candidate-0", ) @@ -74,6 +76,23 @@ def test_candidate_rerun_request_requires_context_hash(): ) +def test_candidate_rerun_request_requires_seed(): + with pytest.raises(RequestValidationError, match="missing required field: seed"): + parse_agent_request_model( + CandidateRerunRequest, + { + "workspaceId": "workspace-1", + "targetStep": "place", + "endStep": "Harden", + "candidateId": "candidate-1", + "patch": [{"knob_id": "place.target_density", "value": 0.6}], + "executionScope": "full_flow", + "idempotencyKey": "episode-1.intervention-1", + "contextSha256": CONTEXT_SHA256, + }, + ) + + def test_agent_request_rejects_duplicate_aliases(): with pytest.raises(RequestValidationError, match="duplicate field: workspace_id"): parse_agent_request_model( @@ -112,6 +131,7 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", "contextSha256": CONTEXT_SHA256, + "seed": 17, }, } ) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 25f1df343..786c34912 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -12,6 +12,7 @@ FlowAgentRuntimeApi, _candidate_rerun_steps, _candidate_step_artifact_dirs, + _materialize_candidate_rerun, _reject_workspace_symlinks, build_agent_flow_for_workspace, ) @@ -189,6 +190,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -207,6 +209,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) assert duplicate["operationId"] == result["operationId"] @@ -278,6 +281,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-2", context_sha256=CONTEXT_SHA256, + seed=17, parent_candidate_root_ref=candidate_root_ref, ) ) @@ -315,6 +319,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-3", context_sha256=CONTEXT_SHA256, + seed=17, parent_candidate_root_ref=candidate_root_ref, ) ) @@ -389,6 +394,8 @@ def test_failed_candidate_returns_materialization_application_and_manifest_evide def run_candidate_step(_flow, step, **_kwargs): if step.name == "place": report = { + "knob_id": "place.target_density", + "requested_value": 0.6, "tool": tool, "application_status": "applied", "effective_initial": {"value": 0.6, "unit": "ratio"}, @@ -435,6 +442,7 @@ def run_candidate_step(_flow, step, **_kwargs): execution_scope="full_flow", idempotency_key="episode-1.failed", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -466,6 +474,42 @@ def run_candidate_step(_flow, step, **_kwargs): execution_receipt["candidate_manifest_sha256"] == terminal["result"]["candidateManifestSha256"] ) + assert terminal["result"]["parameterApplicationReceiptRef"] == ( + ".agent/candidates/candidate-failed/analysis/parameter_application_receipt.v1.json" + ) + assert terminal["result"]["parameterApplicationReceiptSha256"] == sha256_path( + candidate / "analysis" / "parameter_application_receipt.v1.json" + ) + + +def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + for name in ("parameter_runtime_report.v1.json", "parameter_application_receipt.v1.json"): + (analysis / name).write_text('{"stale": true}', encoding="utf-8") + flow = SimpleNamespace( + workspace=SimpleNamespace( + flow=SimpleNamespace(data={"steps": [{"name": "fixFanout"}, {"name": "place"}]}) + ) + ) + request = CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + seed=17, + ) + monkeypatch.setattr("agent.workspace_api.bind_candidate_input", lambda *_args: None) + monkeypatch.setattr("agent.workspace_api.materialize_candidate_config", lambda *_args: None) + + _materialize_candidate_rerun(SimpleNamespace(directory=tmp_path), flow, request) + + assert not (analysis / "parameter_runtime_report.v1.json").exists() + assert not (analysis / "parameter_application_receipt.v1.json").exists() def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): @@ -487,6 +531,7 @@ def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(t execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -508,6 +553,7 @@ def test_candidate_rerun_rejects_invalid_context_hash_before_starting_an_operati execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256="sha256:invalid", + seed=17, ) ) @@ -529,6 +575,7 @@ def test_candidate_rerun_rejects_non_harden_end_step_before_starting_an_operatio execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -550,6 +597,7 @@ def test_candidate_rerun_rejects_unsafe_candidate_id_before_starting_an_operatio execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -573,6 +621,7 @@ def test_candidate_rerun_rejects_unsafe_parent_candidate_ref_before_starting_an_ execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, parent_candidate_root_ref="../outside", ) ) @@ -597,6 +646,7 @@ def test_candidate_rerun_rejects_parent_workspace_symlinks(tmp_path): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) @@ -634,6 +684,7 @@ def fail_copy(*_args, **_kwargs): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + seed=17, ) ) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 59a4016dd..846114b00 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -5,6 +5,7 @@ from hashlib import sha256 from pathlib import Path +import chipcompiler from chipcompiler.runtime.operations import RuntimeOperationConflict, RuntimeOperationFailed from chipcompiler.runtime.requests import WorkspaceIdRequest from chipcompiler.runtime.workspace_api import ( @@ -288,6 +289,8 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: or re.fullmatch(r"sha256:[0-9a-f]{64}", request.context_sha256) is None ): raise RuntimeApiError("invalid_request", "candidate rerun context_sha256 is invalid") + if type(request.seed) is not int: + raise RuntimeApiError("invalid_request", "candidate rerun seed is invalid") if request.parent_candidate_root_ref is not None: _validate_parent_candidate_root_ref(request.parent_candidate_root_ref) @@ -597,6 +600,14 @@ def _candidate_rerun_result( } if parameter_receipt is not None: result["parameterApplicationReceipt"] = parameter_receipt + receipt_ref = f"{candidate_root_ref}/analysis/parameter_application_receipt.v1.json" + receipt_sha256 = sha256_path( + Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" + ) + if receipt_sha256 is None: + raise RuntimeApiError("command_failed", "candidate application receipt is unavailable") + result["parameterApplicationReceiptRef"] = receipt_ref + result["parameterApplicationReceiptSha256"] = receipt_sha256 if evidence_error is not None: result["evidenceError"] = evidence_error return result @@ -641,6 +652,7 @@ def _candidate_parameter_receipt( runtime_report = json.loads(runtime_report_path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise RuntimeApiError("command_failed", "candidate runtime report is unavailable") from exc + _validate_runtime_report_binding(runtime_report, patch, materialization) runtime_tool = runtime_report.get("tool") if isinstance(runtime_report, dict) else None if ( not isinstance(runtime_tool, dict) @@ -653,15 +665,9 @@ def _candidate_parameter_receipt( raise RuntimeApiError("command_failed", "candidate runtime report tool binding is invalid") tool = {key: runtime_tool[key] for key in ("name", "revision", "source_sha256")} receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" - context = ( - _parameter_receipt_context(workspace, request, parent_flow_sha256) - if parent_flow_sha256 is not None - else { - "run_id": request.candidate_id, - "stage": request.target_step, - "lattice_version": "ecos.optimization_lattice.v1", - } - ) + if parent_flow_sha256 is None: + raise RuntimeApiError("command_failed", "candidate parent flow fingerprint is unavailable") + context = _parameter_receipt_context(workspace, request, parent_flow_sha256) context["tool_revision"] = tool["revision"] context["context_sha256"] = request.context_sha256 requested_value = patch["value"] @@ -745,6 +751,12 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d ) knob_name = str(request.patch[0].get("knob_id")) unit = _parameter_unit(knob_name) + ecc_revision = getattr(chipcompiler, "__version__", None) + if not isinstance(ecc_revision, str): + raise RuntimeApiError("command_failed", "candidate ECC revision is unavailable") + ecc_revision = ecc_revision.strip() + if not ecc_revision or ecc_revision == "unknown": + raise RuntimeApiError("command_failed", "candidate ECC revision is unavailable") context = { "run_id": request.candidate_id, "design_sha256": design_sha256, @@ -760,7 +772,8 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d ), "pdk_sha256": pdk_sha256, "parent_lineage_sha256": parent_flow_sha256, - "seed": 0, + "seed": request.seed, + "ecc_revision": ecc_revision, "site_width_dbu": site_width_dbu, "unit": unit, } @@ -768,6 +781,7 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None: + _remove_stale_parameter_receipts(Path(workspace.directory)) source_step = _candidate_source_step(flow, request.target_step) bind_candidate_input( workspace, @@ -784,6 +798,55 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest ) +def _remove_stale_parameter_receipts(workspace_root: Path) -> None: + analysis = workspace_root / "analysis" + for name in ("parameter_runtime_report.v1.json", "parameter_application_receipt.v1.json"): + path = analysis / name + if path.is_symlink(): + raise RuntimeApiError("command_failed", "candidate parameter receipt path is unsafe") + if path.is_file(): + path.unlink() + + +_RUNTIME_CONSUMERS_BY_KNOB = { + "floorplan.core_util": {"ifp.die_builder.die_utilization"}, + "floorplan.aspect_ratio": {"ifp.die_builder.die_aspect_ratio"}, + "synth.max_fanout": {"fixfanout.threshold_compare"}, + "place.target_density": {"dreamplace.density_objective"}, + "place.target_overflow": {"dreamplace.overflow_predicate"}, + "place.cell_padding_x": {"dreamplace.cell_size_expansion"}, + "place.routability_opt": {"dreamplace.routability_branch"}, + "place.density_weight": {"dreamplace.density_preconditioner"}, +} + + +def _validate_runtime_report_binding( + runtime_report: object, + patch: dict, + materialization: dict, +) -> None: + if not isinstance(runtime_report, dict): + raise RuntimeApiError("command_failed", "candidate runtime report is invalid") + knob_id = patch["knob_id"] + written_patch = materialization["patch"][0] + if runtime_report.get("knob_id") != knob_id or runtime_report.get( + "requested_value" + ) != written_patch.get("value"): + raise RuntimeApiError("command_failed", "candidate runtime report binding is invalid") + activation = runtime_report.get("activation") + consumers = activation.get("consumers", []) if isinstance(activation, dict) else [] + if not isinstance(consumers, list): + raise RuntimeApiError("command_failed", "candidate runtime report consumers are invalid") + allowed = _RUNTIME_CONSUMERS_BY_KNOB.get(knob_id, set()) + if any( + not isinstance(consumer, dict) or consumer.get("consumer_id") not in allowed + for consumer in consumers + ): + raise RuntimeApiError( + "command_failed", "candidate runtime report consumer binding is invalid" + ) + + def _candidate_source_step(flow, target_step: str) -> str: steps = flow.workspace.flow.data.get("steps", []) for index, step in enumerate(steps): diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 33f0e4878..1b0ba74f1 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -742,9 +742,6 @@ def run_floorplan( ecc_module.init_fp(config=workspace.config.get(StepEnum.FLOORPLAN.value, "")) sub_flow.update_step(step_name=EccSubFlowEnum.init_floorplan.value, state=StateEnum.Success) - _write_floorplan_parameter_runtime_report( - workspace, workspace.config.get(StepEnum.FLOORPLAN.value, "") - ) ecc_module.run_fp() sub_flow.update_step(step_name=EccSubFlowEnum.create_tracks.value, state=StateEnum.Success) @@ -812,8 +809,14 @@ def _write_floorplan_parameter_runtime_report( requested = patch.get("value") mode = die_builder.get("mode") matches_request = value == requested - status = "used" if mode == "die_util" and value is not None and matches_request else "unknown" - if mode != "die_util" and value is not None and matches_request: + observation = _floorplan_geometry_observation(feature_path, report_path) + complete = _floorplan_observation_complete(observation) + status = ( + "used" + if complete and mode == "die_util" and value is not None and matches_request + else "unknown" + ) + if complete and mode != "die_util" and value is not None and matches_request: status = "not_activated" evidence = { "consumer_id": consumer_id, @@ -827,12 +830,14 @@ def _write_floorplan_parameter_runtime_report( ).hexdigest() ) report = { + "knob_id": knob_id, + "requested_value": requested, "tool": { "name": "ECC-Floorplan", "revision": "ecc.floorplan.parameter_runtime_report.v2", "source_sha256": _runner_source_sha256(), }, - "application_status": "applied" if matches_request else "unknown", + "application_status": "applied" if complete and matches_request else "unknown", "effective_initial": {"value": value, "unit": "ratio"}, "effective_final": {"value": value, "unit": "ratio"}, "activation": { @@ -841,7 +846,6 @@ def _write_floorplan_parameter_runtime_report( }, "transitions": [], } - observation = _floorplan_geometry_observation(feature_path, report_path) if observation is not None: report["consumer_observation"] = observation if feature_path is not None and not _floorplan_observation_complete(observation): diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index a64f861d2..0d11c1cde 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -202,6 +202,8 @@ def _write_parameter_runtime_report( "evidence_sha256": _payload_sha256(evidence_payload), } report = { + "knob_id": knob_id, + "requested_value": patch.get("value"), "tool": { "name": "DREAMPlace", "revision": DREAMPLACE_RUNTIME_REPORT_REVISION, @@ -280,7 +282,7 @@ def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) movable = getattr(placedb, "num_movable_nodes", None) return { - "requested_padding_site": requested, + "requested_padding_dbu": requested, "effective_padding_dbu": effective, "movable_node_count": movable, "placement_iteration_count": iterations, @@ -326,10 +328,10 @@ def _effective_value(knob_id: str, params, observation: dict): def _activation_status(knob_id: str, value, observation: dict, *, engine_succeeded: bool) -> str: - if knob_id == "place.routability_opt" and value in (False, 0): - return "not_activated" if not engine_succeeded or not observation.get("evidence_complete"): return "unknown" + if knob_id == "place.routability_opt" and value in (False, 0): + return "not_activated" if knob_id == "place.routability_opt" and not observation.get("branch_round_count"): return "not_activated" if knob_id == "place.cell_padding_x" and value == 0: diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py index 552f15042..c1e556dcc 100644 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -26,11 +26,36 @@ def _write_config(tmp_path: Path, *, mode: str, field: str, value: float) -> Pat return config_path +def _write_geometry_evidence(tmp_path: Path) -> tuple[Path, Path]: + feature_path = tmp_path / "feature.json" + feature_path.write_text( + json.dumps( + { + "Design Layout": { + "core_area": 800.0, + "core_bounding_width": 40.0, + "core_bounding_height": 20.0, + } + } + ), + encoding="utf-8", + ) + report_path = tmp_path / "report.rpt" + report_path.write_text("Number - Site | 120\nNumber - Row | 30\n", encoding="utf-8") + return feature_path, report_path + + def test_runtime_report_records_native_core_utilization_consumer(tmp_path): _write_candidate(tmp_path, "floorplan.core_util", 0.8) config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) + feature_path, report_path = _write_geometry_evidence(tmp_path) - _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + _write_floorplan_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + config_path, + feature_path=feature_path, + report_path=report_path, + ) report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) source_path = Path(_write_floorplan_parameter_runtime_report.__code__.co_filename) @@ -49,8 +74,14 @@ def test_runtime_report_records_native_core_utilization_consumer(tmp_path): def test_runtime_report_records_native_aspect_ratio_consumer(tmp_path): _write_candidate(tmp_path, "floorplan.aspect_ratio", 1.25) config_path = _write_config(tmp_path, mode="die_util", field="aspect_ratio", value=1.25) + feature_path, report_path = _write_geometry_evidence(tmp_path) - _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + _write_floorplan_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + config_path, + feature_path=feature_path, + report_path=report_path, + ) report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "used" @@ -59,11 +90,28 @@ def test_runtime_report_records_native_aspect_ratio_consumer(tmp_path): ) +def test_runtime_report_does_not_claim_used_without_geometry_evidence(tmp_path): + _write_candidate(tmp_path, "floorplan.core_util", 0.8) + config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) + + _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["application_status"] == "unknown" + assert report["activation"] == {"status": "unknown", "consumers": []} + + def test_runtime_report_marks_die_size_mode_not_activated(tmp_path): _write_candidate(tmp_path, "floorplan.core_util", 0.8) config_path = _write_config(tmp_path, mode="die_size", field="utilization", value=0.8) + feature_path, report_path = _write_geometry_evidence(tmp_path) - _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) + _write_floorplan_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + config_path, + feature_path=feature_path, + report_path=report_path, + ) report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "not_activated" diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 48ffde664..547216e9e 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -195,11 +195,11 @@ def test_runtime_report_preserves_consumed_cell_padding_after_restore(tmp_path): "evidence_complete": True, "movable_node_count": 12, "placement_iteration_count": 3, - "requested_padding_site": 400, + "requested_padding_dbu": 400, } -def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): +def test_runtime_report_does_not_mark_disabled_routability_without_gate_evidence(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() (analysis / "candidate_materialization.v1.json").write_text( @@ -213,7 +213,28 @@ def test_runtime_report_marks_disabled_routability_not_activated(tmp_path): ppa={"iteration": 3}, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) + assert report["activation"]["status"] == "unknown" + assert report["consumer_observation"]["evidence_complete"] is False + + +def test_runtime_report_marks_disabled_routability_not_activated_with_gate_evidence(tmp_path): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": False}]}), + encoding="utf-8", + ) + _write_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), + SimpleNamespace(routability_opt_flag=False), + engine=SimpleNamespace(native_runtime_probe={"routability_branch_round_count": 0}), + ppa={"iteration": 3}, + engine_succeeded=True, + ) + report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) assert report["activation"]["status"] == "not_activated" + assert report["application_status"] == "applied" + assert report["consumer_observation"]["evidence_complete"] is True def test_runtime_report_requires_a_native_routability_round(tmp_path): From 86e08451c6148b74bbc2a56b4c2ed0e5c815a3bb Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 29 Aug 2026 17:29:21 +0800 Subject: [PATCH 49/90] fix: enforce parameter execution evidence --- .../test/test_parameter_receipt_artifacts.py | 45 +++++++++++++- agent/test/test_workspace_api.py | 58 ++++++++++++++++--- agent/workspace_api.py | 38 +++++++++--- chipcompiler/tools/ecc/runner.py | 2 +- .../ecc/test_floorplan_runtime_report.py | 2 + test/tools/ecc_dreamplace/test_module.py | 2 + 6 files changed, 130 insertions(+), 17 deletions(-) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 6d26241fe..67c4c0c03 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -12,7 +12,11 @@ materialize_candidate_config, ) from agent.data.parameter_application_receipt import build_parameter_application_receipt -from agent.workspace_api import _candidate_parameter_receipt +from agent.workspace_api import ( + _candidate_parameter_receipt, + _parameter_receipt_context, + _stable_hash, +) from chipcompiler.runtime.workspace_api import RuntimeApiError HASH = "sha256:" + "a" * 64 @@ -127,6 +131,45 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No assert materialization_ref["receipt_sha256"] != materialization_ref["registry_sha256"] +def test_parameter_receipt_context_aggregates_all_rtl_and_sdc_files(tmp_path: Path) -> None: + workspace, _ = _materialized_workspace( + tmp_path, + candidate_id="candidate-multifile", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + origin = tmp_path / "origin" + (origin / "rtl" / "worker.v").write_text("module worker; endmodule\n", encoding="utf-8") + (origin / "timing.sdc").write_text("set_input_delay 1 clk\n", encoding="utf-8") + (origin / "filelist.f").write_text("rtl/top.v\nrtl/worker.v\n", encoding="utf-8") + request = SimpleNamespace( + candidate_id="candidate-multifile", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + seed=17, + ) + + context = _parameter_receipt_context(workspace, request, HASH) + + rtl_sha256 = _stable_hash( + {"files": [sha256_path(path) for path in sorted((origin / "rtl").glob("*"))]} + ) + sdc_sha256 = _stable_hash( + {"files": [sha256_path(path) for path in sorted(origin.glob("*.sdc"))]} + ) + filelist_sha256 = sha256_path(origin / "filelist.f") + assert context["rtl_sha256"] == rtl_sha256 + assert context["sdc_sha256"] == sdc_sha256 + assert context["design_sha256"] == _stable_hash( + { + "rtl_sha256": rtl_sha256, + "filelist_sha256": filelist_sha256, + "sdc_sha256": sdc_sha256, + } + ) + + def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monkeypatch) -> None: workspace, materialization = _materialized_workspace( tmp_path, diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 786c34912..d2ca68d02 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -140,7 +140,12 @@ def build_flow(candidate_workspace, *, create_step_workspaces=True): SimpleNamespace( name="Harden", tool="ecc", - output={"dir": root / "Harden_ecc" / "output"}, + output=EccOutput( + dir=root / "Harden_ecc" / "output", + gds=root / "Harden_ecc" / "output" / "gcd_Harden.gds", + lef=root / "Harden_ecc" / "output" / "gcd_Harden.lef", + lib=root / "Harden_ecc" / "output" / "gcd_Harden.lib", + ), ), ), ) @@ -240,13 +245,14 @@ def materialize(candidate_workspace, target, patch, candidate): assert (tmp_path / "place_dreamplace" / "analysis" / "stale").is_file() assert (tmp_path / "CTS_ecc" / "output" / "stale").is_file() assert (tmp_path / "Harden_ecc" / "output" / "stale").is_file() - assert (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") == ( - '{"target_density": 0.6}\n' - ) + assert json.loads( + (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") + ) == {"random_seed": 17, "target_density": 0.6} + assert flows[0].observed_random_seeds == [17] assert not list((candidate_root / "place_dreamplace" / "output").iterdir()) assert not list((candidate_root / "place_dreamplace" / "analysis").iterdir()) assert not list((candidate_root / "CTS_ecc" / "output").iterdir()) - assert not list((candidate_root / "Harden_ecc" / "output").iterdir()) + assert not (candidate_root / "Harden_ecc" / "output" / "stale").exists() candidate_manifest = candidate_root / "analysis" / "candidate_workspace.v1.json" result = terminal["result"] assert {key: value for key, value in result.items() if key != "candidateManifestSha256"} == { @@ -264,6 +270,23 @@ def materialize(candidate_workspace, target, patch, candidate): assert first_manifest["terminal_state"] == "succeeded" assert first_manifest["candidate_state_sha256"].startswith("sha256:") assert "candidate_execution_receipt" not in first_manifest["artifacts"] + assert { + key: first_manifest["artifacts"].get(key) + for key in ("harden_gds", "harden_lef", "harden_lib") + } == { + "harden_gds": { + "ref": "Harden_ecc/output/gcd_Harden.gds", + "sha256": sha256_path(candidate_root / "Harden_ecc/output/gcd_Harden.gds"), + }, + "harden_lef": { + "ref": "Harden_ecc/output/gcd_Harden.lef", + "sha256": sha256_path(candidate_root / "Harden_ecc/output/gcd_Harden.lef"), + }, + "harden_lib": { + "ref": "Harden_ecc/output/gcd_Harden.lib", + "sha256": sha256_path(candidate_root / "Harden_ecc/output/gcd_Harden.lib"), + }, + } execution_receipt = json.loads( (candidate_root / "analysis" / "candidate_execution_receipt.v1.json").read_text( encoding="utf-8" @@ -291,7 +314,11 @@ def materialize(candidate_workspace, target, patch, candidate): tmp_path / ".agent" / "candidates" / "candidate-2" / "config" / "dreamplace.json" ).read_text(encoding="utf-8") ) - assert second_config == {"routability_opt": True, "target_density": 0.6} + assert second_config == { + "random_seed": 17, + "routability_opt": True, + "target_density": 0.6, + } second_manifest = json.loads( ( tmp_path @@ -485,6 +512,9 @@ def run_candidate_step(_flow, step, **_kwargs): def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() + dreamplace = tmp_path / "config" / "dreamplace.json" + dreamplace.parent.mkdir() + dreamplace.write_text('{"random_seed": 3000}', encoding="utf-8") for name in ("parameter_runtime_report.v1.json", "parameter_application_receipt.v1.json"): (analysis / name).write_text('{"stale": true}', encoding="utf-8") flow = SimpleNamespace( @@ -506,10 +536,13 @@ def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, monkeypatch.setattr("agent.workspace_api.bind_candidate_input", lambda *_args: None) monkeypatch.setattr("agent.workspace_api.materialize_candidate_config", lambda *_args: None) - _materialize_candidate_rerun(SimpleNamespace(directory=tmp_path), flow, request) + _materialize_candidate_rerun( + SimpleNamespace(directory=tmp_path, config={"dreamplace": dreamplace}), flow, request + ) assert not (analysis / "parameter_runtime_report.v1.json").exists() assert not (analysis / "parameter_application_receipt.v1.json").exists() + assert json.loads(dreamplace.read_text(encoding="utf-8"))["random_seed"] == 17 def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(tmp_path): @@ -708,6 +741,8 @@ def _load_workspace(self, directory): flow_path = root / "home" / "flow.json" return SimpleNamespace( directory=root, + config={"dreamplace": root / "config" / "dreamplace.json"}, + design=SimpleNamespace(name="gcd"), flow=SimpleNamespace( data=json.loads(flow_path.read_text(encoding="utf-8")), path=flow_path ), @@ -732,6 +767,7 @@ def __init__(self, workspace, workspace_steps): self.created = False self.initialize_config = None self.run_calls = [] + self.observed_random_seeds = [] def create_step_workspaces(self, *, initialize_config=True): self.workspace_steps = self._workspace_steps @@ -754,6 +790,14 @@ def save(self): def run_step(self, step, *, rerun, observer=None): self.run_calls.append((step.name, rerun)) + if step.name == "place": + config = json.loads( + Path(self.workspace.config["dreamplace"]).read_text(encoding="utf-8") + ) + self.observed_random_seeds.append(config.get("random_seed")) + if step.name == "Harden": + for artifact in (step.output.gds, step.output.lef, step.output.lib): + Path(artifact).write_text(step.name, encoding="utf-8") if observer is not None: observer.on_step_started(step) observer.on_step_completed(step, StateEnum.Success) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 846114b00..5188d04cf 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -520,6 +520,21 @@ def _candidate_workspace_receipt( "ref": relative, "sha256": _required_file_sha256(artifact, key), } + if terminal_state == "succeeded": + design_name = getattr(getattr(workspace, "design", None), "name", None) + if not isinstance(design_name, str) or not design_name: + raise RuntimeApiError("command_failed", "candidate design name is unavailable") + for key, suffix in ( + ("harden_gds", "gds"), + ("harden_lef", "lef"), + ("harden_lib", "lib"), + ): + relative = f"Harden_ecc/output/{design_name}_Harden.{suffix}" + artifact = candidate_root / relative + artifacts[key] = { + "ref": relative, + "sha256": _required_file_sha256(artifact, key), + } manifest["artifacts"] = artifacts try: write_json_atomic(manifest_path, manifest) @@ -742,11 +757,13 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d if not filelist.is_file(): raise RuntimeApiError("command_failed", "candidate filelist fingerprint is unavailable") filelist_sha256 = f"sha256:{sha256(filelist.read_bytes()).hexdigest()}" + rtl_sha256 = rtl_hashes[0] if len(rtl_hashes) == 1 else _stable_hash({"files": rtl_hashes}) + sdc_sha256 = sdc_hashes[0] if len(sdc_hashes) == 1 else _stable_hash({"files": sdc_hashes}) design_sha256 = _stable_hash( { - "rtl_sha256": rtl_hashes[0], + "rtl_sha256": rtl_sha256, "filelist_sha256": filelist_sha256, - "sdc_sha256": sdc_hashes[0], + "sdc_sha256": sdc_sha256, } ) knob_name = str(request.patch[0].get("knob_id")) @@ -763,13 +780,9 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d "stage": request.target_step, "backend": "ecc", "lattice_version": "ecos.optimization_lattice.v1", - "rtl_sha256": ( - rtl_hashes[0] if len(rtl_hashes) == 1 else _stable_hash({"files": rtl_hashes}) - ), + "rtl_sha256": rtl_sha256, "filelist_sha256": filelist_sha256, - "sdc_sha256": ( - sdc_hashes[0] if len(sdc_hashes) == 1 else _stable_hash({"files": sdc_hashes}) - ), + "sdc_sha256": sdc_sha256, "pdk_sha256": pdk_sha256, "parent_lineage_sha256": parent_flow_sha256, "seed": request.seed, @@ -790,6 +803,15 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest source_step, request.candidate_id, ) + dreamplace_path = Path(workspace.config["dreamplace"]) + try: + dreamplace_config = json.loads(dreamplace_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeApiError("command_failed", "candidate DREAMPlace config is invalid") from exc + if not isinstance(dreamplace_config, dict): + raise RuntimeApiError("command_failed", "candidate DREAMPlace config is invalid") + dreamplace_config["random_seed"] = request.seed + write_json_atomic(dreamplace_path, dreamplace_config) materialize_candidate_config( workspace, request.target_step, diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 1b0ba74f1..7930a9ebd 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -820,7 +820,7 @@ def _write_floorplan_parameter_runtime_report( status = "not_activated" evidence = { "consumer_id": consumer_id, - "outcome": "entered" if status == "used" else "evaluated", + "outcome": "geometry_constructed" if status == "used" else "evaluated", "evidence_ref": "analysis/parameter_runtime_report.v1.json", } evidence["evidence_sha256"] = ( diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py index c1e556dcc..2c5b1a9f8 100644 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -68,6 +68,7 @@ def test_runtime_report_records_native_core_utilization_consumer(tmp_path): assert report["activation"]["consumers"][0]["consumer_id"] == ( "ifp.die_builder.die_utilization" ) + assert report["activation"]["consumers"][0]["outcome"] == "geometry_constructed" assert report["effective_final"] == {"value": 0.8, "unit": "ratio"} @@ -88,6 +89,7 @@ def test_runtime_report_records_native_aspect_ratio_consumer(tmp_path): assert report["activation"]["consumers"][0]["consumer_id"] == ( "ifp.die_builder.die_aspect_ratio" ) + assert report["activation"]["consumers"][0]["outcome"] == "geometry_constructed" def test_runtime_report_does_not_claim_used_without_geometry_evidence(tmp_path): diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index 883876245..f6e6c7d14 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -17,6 +17,7 @@ def test_build_params_preserves_routability_config_and_forces_timing_off(tmp_pat json_write( config_path, { + "random_seed": 17, "routability_opt_flag": 1, "get_congestion_map": 1, "with_sta": True, @@ -49,6 +50,7 @@ def test_build_params_preserves_routability_config_and_forces_timing_off(tmp_pat params = module._build_params(FakeParams, legalize_only=False) assert params.routability_opt_flag == 1 + assert params.random_seed == 17 assert params.get_congestion_map == 1 assert params.with_sta is False assert params.timing_opt_flag == 0 From 851bee58cf0ebe38391a8f83f1020832d2265311 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 2 Sep 2026 11:19:45 +0800 Subject: [PATCH 50/90] fix: resume materialized candidates from failed step --- agent/candidate_resume.py | 393 ++++++++++++++++++ agent/data/candidate_materialization.py | 2 + agent/methods.py | 6 + agent/requests.py | 9 + .../data/test_candidate_materialization.py | 9 +- agent/test/test_candidate_resume.py | 283 +++++++++++++ agent/test/test_requests.py | 39 +- agent/workspace_api.py | 8 + 8 files changed, 744 insertions(+), 5 deletions(-) create mode 100644 agent/candidate_resume.py create mode 100644 agent/test/test_candidate_resume.py diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py new file mode 100644 index 000000000..4da6ef7fb --- /dev/null +++ b/agent/candidate_resume.py @@ -0,0 +1,393 @@ +"""Resume a failed materialized candidate in its existing workspace.""" + +import json +import os +import re +import tempfile +from pathlib import Path + +from chipcompiler.runtime.operations import RuntimeOperationConflict, RuntimeOperationFailed +from chipcompiler.runtime.workspace_api import RuntimeApiError, _state_value +from chipcompiler.utility.path import path_is_within + +from .data.candidate_artifacts import validate_candidate_id +from .data.candidate_materialization import ( + candidate_written_patch, + reapply_materialized_candidate_config, + validate_candidate_materialization_receipt, +) +from .requests import CandidateRerunRequest, CandidateResumeRequest +from .workspace_api import ( + _CANDIDATE_WORKSPACE_MANIFEST, + _CANDIDATE_WORKSPACE_SCHEMA, + _IDEMPOTENCY_KEY, + _candidate_parent_binding, + _candidate_rerun_result, + _candidate_rerun_steps, + _parent_workspace_root, + _prepare_candidate_rerun, + _reapply_candidate_input, + _required_file_sha256, + _run_candidate_step, + _workspace_state_sha256, +) + + +def candidate_resume(api, request: CandidateResumeRequest) -> dict: + _validate_candidate_resume_request(request) + api.ecc_api._get_session(request.workspace_id) + try: + return api.ecc_api.operations.start( + workspace_id=request.workspace_id, + kind="candidate_resume", + origin="agent", + rerun=True, + step="Harden", + idempotency_key=request.idempotency_key, + runner=lambda observer: api._with_workspace_lock( + request.workspace_id, + lambda session: _candidate_resume(api, session, request, observer), + ), + ) + except RuntimeOperationConflict as exc: + raise RuntimeApiError("command_failed", str(exc)) from exc + + +def _candidate_resume(api, session, request: CandidateResumeRequest, observer) -> dict: + candidate_workspace = flow = rerun_request = parent = None + candidate_root_ref = f".agent/candidates/{request.candidate_id}" + resume_step = None + evidence_ready = False + try: + candidate_workspace, manifest, parent = _load_candidate_resume( + api.ecc_api, session.workspace, request.candidate_id + ) + flow = api._build_flow(candidate_workspace, create_step_workspaces=False) + create_step_workspaces = getattr(flow, "create_step_workspaces", None) + if callable(create_step_workspaces): + create_step_workspaces(initialize_config=False) + steps = _candidate_resume_steps(flow, manifest["target_step"]) + resume_step = steps[0].name + patch = _validate_candidate_resume_binding(candidate_workspace, flow, manifest, request) + rerun_request = _candidate_resume_rerun_request(manifest, request, patch) + evidence_ready = True + _prepare_candidate_rerun(candidate_workspace, flow, steps) + _notify_candidate_resume_prepared(observer, steps, manifest["target_step"]) + for step in steps: + _run_candidate_step(flow, step, observer=observer) + result = _candidate_rerun_result( + candidate_workspace, + rerun_request, + candidate_root_ref, + parent, + terminal_state="succeeded", + ) + result["resumeStep"] = resume_step + return result + except Exception as exc: + result = _candidate_resume_failure_result( + request, + candidate_workspace, + rerun_request, + candidate_root_ref, + parent, + resume_step, + evidence_ready=evidence_ready, + ) + raise RuntimeOperationFailed( + str(exc), code=getattr(exc, "code", "command_failed"), result=result + ) from exc + finally: + if flow is not None: + api.ecc_api._close_transient_flow_db(flow) + + +def _candidate_resume_failure_result( + request, + workspace, + rerun_request, + candidate_root_ref: str, + parent, + resume_step, + *, + evidence_ready: bool, +) -> dict: + result = {"candidateId": request.candidate_id, "candidateRootRef": candidate_root_ref} + if not evidence_ready: + return result + try: + result = _candidate_rerun_result( + workspace, + rerun_request, + candidate_root_ref, + parent, + terminal_state="failed", + ) + if resume_step is not None: + result["resumeStep"] = resume_step + except Exception as evidence_error: + result["evidenceError"] = str(evidence_error) + return result + + +def _validate_candidate_resume_request(request: CandidateResumeRequest) -> None: + if not isinstance(request.workspace_id, str) or not request.workspace_id.strip(): + raise RuntimeApiError("invalid_request", "candidate resume workspace_id is invalid") + try: + validate_candidate_id(request.candidate_id) + except ValueError as exc: + raise RuntimeApiError( + "invalid_request", "candidate resume candidate_id is invalid" + ) from exc + if not isinstance(request.idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch( + request.idempotency_key + ): + raise RuntimeApiError("invalid_request", "candidate resume idempotency key is invalid") + if ( + not isinstance(request.context_sha256, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", request.context_sha256) is None + ): + raise RuntimeApiError("invalid_request", "candidate resume context_sha256 is invalid") + if type(request.seed) is not int: + raise RuntimeApiError("invalid_request", "candidate resume seed is invalid") + + +def _candidate_resume_steps(flow, target_step: str) -> list: + steps = _candidate_rerun_steps(flow, target_step, "Harden", "full_flow") + for index, step in enumerate(steps): + record = flow.get_step(step.name, step.tool) + if record is None: + raise RuntimeApiError("command_failed", f"candidate flow state is missing: {step.name}") + if _state_value(record.get("state")) != "Success": + return steps[index:] + raise RuntimeApiError("command_failed", "failed candidate has no resumable step") + + +def _load_candidate_resume(ecc_api, workspace, candidate_id: str): + workspace_root = _parent_workspace_root(workspace) + candidate_root_ref = f".agent/candidates/{validate_candidate_id(candidate_id)}" + candidate_root = workspace_root / candidate_root_ref + if ( + candidate_root.is_symlink() + or not candidate_root.is_dir() + or candidate_root.resolve() != candidate_root.absolute() + ): + raise RuntimeApiError("command_failed", "candidate resume workspace is unavailable") + manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeApiError("command_failed", "candidate resume manifest is invalid") from exc + _validate_candidate_resume_manifest( + workspace_root, candidate_root, candidate_root_ref, manifest + ) + parent = _candidate_parent_binding(workspace_root, manifest.get("parent_candidate_root_ref")) + _validate_candidate_resume_parent(manifest, parent) + candidate_workspace = ecc_api._load_workspace(str(candidate_root)) + if Path(candidate_workspace.directory).resolve() != candidate_root: + raise RuntimeApiError("command_failed", "candidate resume workspace escaped its root") + return candidate_workspace, manifest, parent + + +def _validate_candidate_resume_manifest( + workspace_root: Path, candidate_root: Path, candidate_root_ref: str, manifest: object +) -> None: + expected = { + "schema": _CANDIDATE_WORKSPACE_SCHEMA, + "schema_version": 1, + "candidate_id": Path(candidate_root_ref).name, + "candidate_root_ref": candidate_root_ref, + "terminal_state": "failed", + "end_step": "Harden", + "execution_scope": "full_flow", + "candidate_flow_sha256": _required_file_sha256( + candidate_root / "home" / "flow.json", "resume flow" + ), + "candidate_state_sha256": _workspace_state_sha256(candidate_root), + } + if not isinstance(manifest, dict) or any( + manifest.get(key) != value for key, value in expected.items() + ): + raise RuntimeApiError("command_failed", "candidate resume manifest binding is invalid") + if not isinstance(manifest.get("target_step"), str) or not manifest["target_step"]: + raise RuntimeApiError("command_failed", "candidate resume target step is invalid") + _validate_candidate_resume_artifacts(candidate_root, manifest.get("artifacts")) + try: + candidate_root.relative_to(workspace_root) + except ValueError as exc: + raise RuntimeApiError( + "command_failed", "candidate resume workspace escaped its parent" + ) from exc + + +def _validate_candidate_resume_artifacts(candidate_root: Path, artifacts: object) -> None: + required = { + "candidate_materialization": "analysis/candidate_materialization.v1.json", + "candidate_input_binding": "analysis/candidate_input_binding.v1.json", + } + if not isinstance(artifacts, dict) or any( + not isinstance(artifacts.get(key), dict) or artifacts[key].get("ref") != ref + for key, ref in required.items() + ): + raise RuntimeApiError("command_failed", "candidate resume receipt is missing") + for name, artifact in artifacts.items(): + if not isinstance(name, str) or not isinstance(artifact, dict): + raise RuntimeApiError("command_failed", "candidate resume artifact binding is invalid") + ref = artifact.get("ref") + path = candidate_root / ref if isinstance(ref, str) else candidate_root.parent + if ( + not isinstance(ref, str) + or Path(ref).is_absolute() + or not path_is_within(path.resolve(), candidate_root) + or artifact.get("sha256") != _required_file_sha256(path, "resume artifact") + ): + raise RuntimeApiError("command_failed", "candidate resume artifact binding is invalid") + + +def _validate_candidate_resume_parent(manifest: dict, parent: dict) -> None: + expected = { + "parent_candidate_root_ref": parent["root_ref"], + "parent_manifest_ref": parent["manifest_ref"], + "parent_manifest_sha256": parent["manifest_sha256"], + "parent_flow_sha256": parent["flow_sha256"], + "parent_state_sha256": parent["state_sha256"], + } + if any(manifest.get(key) != value for key, value in expected.items()): + raise RuntimeApiError("command_failed", "candidate resume parent binding is invalid") + + +def _candidate_resume_rerun_request( + manifest: dict, request: CandidateResumeRequest, patch: list[dict] +) -> CandidateRerunRequest: + return CandidateRerunRequest( + workspace_id=request.workspace_id, + target_step=manifest["target_step"], + end_step="Harden", + candidate_id=request.candidate_id, + patch=patch, + execution_scope="full_flow", + idempotency_key=request.idempotency_key, + context_sha256=request.context_sha256, + seed=request.seed, + parent_candidate_root_ref=manifest["parent_candidate_root_ref"], + ) + + +def _validate_candidate_resume_binding( + workspace, flow, manifest: dict, request: CandidateResumeRequest +) -> list[dict]: + backups = _candidate_resume_config_backups(workspace) + try: + return _validated_candidate_resume_patch(workspace, flow, manifest, request) + except Exception: + try: + _restore_candidate_resume_configs(workspace, backups) + except OSError as rollback_error: + raise RuntimeApiError( + "command_failed", "candidate resume config rollback failed" + ) from rollback_error + raise + + +def _validated_candidate_resume_patch( + workspace, flow, manifest: dict, request: CandidateResumeRequest +) -> list[dict]: + target_step = manifest["target_step"] + try: + reapply_materialized_candidate_config(workspace, target_step) + materialization = validate_candidate_materialization_receipt(workspace, target_step) + if materialization is None or materialization["candidate_id"] != request.candidate_id: + raise ValueError("candidate materialization receipt is missing or mismatched") + _reapply_candidate_input(workspace, flow, target_step) + except ValueError as exc: + raise RuntimeApiError( + "command_failed", f"candidate resume receipt binding is invalid: {exc}" + ) from exc + dreamplace_path = Path(workspace.config["dreamplace"]) + try: + dreamplace = json.loads(dreamplace_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeApiError("command_failed", "candidate resume seed binding is invalid") from exc + if not isinstance(dreamplace, dict) or dreamplace.get("random_seed") != request.seed: + raise RuntimeApiError("command_failed", "candidate resume seed binding is invalid") + requested_patch = _candidate_resume_requested_patch( + workspace, manifest, request, materialization["patch"], target_step + ) + try: + written_patch = candidate_written_patch(workspace, target_step, requested_patch) + except ValueError as exc: + raise RuntimeApiError( + "command_failed", "candidate resume requested patch binding is invalid" + ) from exc + if written_patch != materialization["patch"]: + raise RuntimeApiError( + "command_failed", "candidate resume requested patch binding is invalid" + ) + return requested_patch + + +def _candidate_resume_requested_patch( + workspace, + manifest: dict, + request: CandidateResumeRequest, + materialized_patch: list[dict], + target_step: str, +) -> list[dict]: + application = manifest["artifacts"].get("parameter_application_receipt") + if application is None: + return materialized_patch + try: + receipt = json.loads( + (Path(workspace.directory) / application["ref"]).read_text(encoding="utf-8") + ) + context = receipt["context"] + requested = receipt["requested"] + except (KeyError, OSError, TypeError, json.JSONDecodeError) as exc: + raise RuntimeApiError( + "command_failed", "candidate resume context binding is invalid" + ) from exc + if ( + context.get("context_sha256") != request.context_sha256 + or context.get("seed") != request.seed + or context.get("run_id") != request.candidate_id + or context.get("stage") != target_step + ): + raise RuntimeApiError("command_failed", "candidate resume context binding is invalid") + return [{"knob_id": requested.get("knob_id"), "value": requested.get("value")}] + + +def _candidate_resume_config_backups(workspace) -> dict[Path, bytes]: + root = Path(workspace.directory) + relatives = ( + "home/parameters.json", + "config/floorplan_ecc.json", + "config/fixfanout_ecc.json", + "config/dreamplace_ecc.json", + "config/dreamplace.json", + ) + paths = [root / relative for relative in relatives] + if any(path.is_symlink() for path in paths): + raise RuntimeApiError("command_failed", "candidate resume config path is unsafe") + return {path: path.read_bytes() for path in paths if path.is_file()} + + +def _restore_candidate_resume_configs(workspace, backups: dict[Path, bytes]) -> None: + for path, content in backups.items(): + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary: + temporary.write(content) + temporary_path = Path(temporary.name) + os.replace(temporary_path, path) + parameters = getattr(workspace, "parameters", None) + parameters_path = getattr(parameters, "path", None) + if parameters_path and Path(parameters_path) in backups: + parameters.data = json.loads(backups[Path(parameters_path)]) + + +def _notify_candidate_resume_prepared(observer, steps: list, target_step: str) -> None: + callback = getattr(observer, "on_rerun_prepared", None) + if callable(callback): + callback( + affected_steps=[str(step.name) for step in steps], + scope="full_flow", + target_step=target_step, + ) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index f99809057..31d20750c 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -76,6 +76,8 @@ def reapply_materialized_candidate_config( if not receipt_path.exists(): return None receipt = _read_receipt(receipt_path) + if receipt["target_step"] != target_step: + return None _validate_receipt_binding(workspace, target_step, receipt) _verify_config_snapshot_hashes(workspace, receipt["snapshots"]) snapshots = {entry["config_key"]: entry for entry in receipt["snapshots"]} diff --git a/agent/methods.py b/agent/methods.py index 14419ea22..cb4a3aff1 100644 --- a/agent/methods.py +++ b/agent/methods.py @@ -7,6 +7,7 @@ CandidateBindInputRequest, CandidateMaterializeRequest, CandidateRerunRequest, + CandidateResumeRequest, WorkspaceExtractFoundationRequest, ) @@ -36,6 +37,11 @@ request_model=CandidateRerunRequest, handler_name="candidate_rerun", ), + RuntimeMethodSpec( + method_name="candidate.resume", + request_model=CandidateResumeRequest, + handler_name="candidate_resume", + ), ) diff --git a/agent/requests.py b/agent/requests.py index 1522ae54c..1fec8daf6 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -39,6 +39,15 @@ class CandidateRerunRequest: parent_candidate_root_ref: str | None = None +@dataclass(frozen=True) +class CandidateResumeRequest: + workspace_id: str + candidate_id: str + idempotency_key: str + context_sha256: str + seed: int + + _FIELD_ALIASES = { "workspaceId": "workspace_id", "targetStep": "target_step", diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index 1d3995331..2d7bb3440 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -297,8 +297,10 @@ def test_receipt_target_mismatch_is_fail_closed(tmp_path): with pytest.raises(CandidateMaterializationError, match="target step mismatch"): validate_candidate_materialization_receipt(workspace, "route") - with pytest.raises(CandidateMaterializationError, match="target step mismatch"): - reapply_materialized_candidate_config(workspace, "route") + before = _read_json(workspace.config["dreamplace"]) + + assert reapply_materialized_candidate_config(workspace, "route") is None + assert _read_json(workspace.config["dreamplace"]) == before @pytest.mark.parametrize( @@ -485,8 +487,7 @@ def test_reapply_after_refresh_restores_only_matching_target_and_updates_hashes( current[path[-1]] = reset_value _write_json(config_path, refreshed_config) - with pytest.raises(CandidateMaterializationError, match="target step mismatch"): - reapply_materialized_candidate_config(workspace, "route") + assert reapply_materialized_candidate_config(workspace, "route") is None unchanged = _read_json(config_path) current = unchanged for key in path: diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py new file mode 100644 index 000000000..bfa1ac25b --- /dev/null +++ b/agent/test/test_candidate_resume.py @@ -0,0 +1,283 @@ +import json +import threading +from types import SimpleNamespace + +import pytest + +from agent.candidate_resume import ( + _candidate_resume_steps, + _validate_candidate_resume_binding, + _validate_candidate_resume_manifest, +) +from agent.data.candidate_artifacts import sha256_path +from agent.data.candidate_materialization import materialize_candidate_config +from agent.requests import CandidateResumeRequest +from agent.workspace_api import FlowAgentRuntimeApi, _workspace_state_sha256 +from chipcompiler.runtime.operations import RuntimeOperationManager +from chipcompiler.runtime.workspace_api import RuntimeApiError + +CONTEXT_SHA256 = "sha256:" + "a" * 64 + + +def test_candidate_resume_slice_starts_at_first_non_success_step() -> None: + records = [ + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Incomplete"}, + {"name": "Harden", "tool": "ecc", "state": "Unstart"}, + ] + steps = tuple(SimpleNamespace(name=item["name"], tool=item["tool"]) for item in records) + flow = SimpleNamespace( + workspace_steps=steps, + get_step=lambda name, tool: next( + item for item in records if item["name"] == name and item["tool"] == tool + ), + ) + + resumed = _candidate_resume_steps(flow, "place") + + assert [step.name for step in resumed] == ["CTS", "Harden"] + + +def test_candidate_resume_runs_in_place_and_preserves_successful_target_artifacts( + monkeypatch, tmp_path +) -> None: + candidate_id = "candidate-1" + candidate = tmp_path / ".agent" / "candidates" / candidate_id + flow_data = { + "steps": [ + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Incomplete"}, + {"name": "Harden", "tool": "ecc", "state": "Unstart"}, + ] + } + flow_path = candidate / "home" / "flow.json" + flow_path.parent.mkdir(parents=True) + flow_path.write_text(json.dumps(flow_data), encoding="utf-8") + config = candidate / "config" / "dreamplace.json" + config.parent.mkdir() + config.write_text('{"random_seed": 17}', encoding="utf-8") + workspace = SimpleNamespace( + directory=candidate, + config={"dreamplace": config}, + flow=SimpleNamespace(data=flow_data, path=flow_path), + ) + target_output = candidate / "place_dreamplace" / "output" + cts_output = candidate / "CTS_ecc" / "output" + harden_output = candidate / "Harden_ecc" / "output" + for directory in (target_output, cts_output, harden_output): + directory.mkdir(parents=True) + (directory / "existing").write_text("evidence", encoding="utf-8") + steps = ( + SimpleNamespace(name="place", tool="dreamplace", output={"dir": target_output}), + SimpleNamespace(name="CTS", tool="ecc", output={"dir": cts_output}), + SimpleNamespace(name="Harden", tool="ecc", output={"dir": harden_output}), + ) + flow = _Flow(workspace, steps) + parent = { + "root_ref": None, + "manifest_ref": None, + "manifest_sha256": None, + "flow_sha256": "sha256:" + "1" * 64, + "state_sha256": "sha256:" + "2" * 64, + } + manifest = {"target_step": "place", "parent_candidate_root_ref": None} + api = FlowAgentRuntimeApi(_EccApi(SimpleNamespace(directory=tmp_path))) + monkeypatch.setattr( + "agent.candidate_resume._load_candidate_resume", + lambda *_args: (workspace, manifest, parent), + ) + monkeypatch.setattr(api, "_build_flow", lambda *_args, **_kwargs: flow) + monkeypatch.setattr( + "agent.candidate_resume._validate_candidate_resume_binding", + lambda *_args: [{"knob_id": "place.target_density", "value": 0.6}], + ) + run_steps = [] + monkeypatch.setattr( + "agent.candidate_resume._run_candidate_step", + lambda _flow, step, **_kwargs: run_steps.append(step.name), + ) + monkeypatch.setattr( + "agent.candidate_resume._candidate_rerun_result", + lambda *_args, **_kwargs: {"candidateId": candidate_id}, + ) + + started = api.candidate_resume( + CandidateResumeRequest( + workspace_id="workspace-1", + candidate_id=candidate_id, + idempotency_key="episode-1.resume-1", + context_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + terminal = _wait_for_terminal(api.ecc_api.operations, started["operationId"]) + + assert terminal["result"] == {"candidateId": candidate_id, "resumeStep": "CTS"} + assert run_steps == ["CTS", "Harden"] + assert (target_output / "existing").is_file() + assert not (cts_output / "existing").exists() + assert not (harden_output / "existing").exists() + + +def test_candidate_resume_manifest_rejects_illegal_state_missing_receipt_and_state_drift( + tmp_path, +) -> None: + candidate_id = "candidate-1" + candidate_ref = f".agent/candidates/{candidate_id}" + candidate = tmp_path / candidate_ref + flow = candidate / "home" / "flow.json" + flow.parent.mkdir(parents=True) + flow.write_text('{"steps": []}', encoding="utf-8") + config = candidate / "config" / "dreamplace.json" + config.parent.mkdir() + config.write_text('{"random_seed": 17}', encoding="utf-8") + analysis = candidate / "analysis" + analysis.mkdir() + materialization = analysis / "candidate_materialization.v1.json" + input_binding = analysis / "candidate_input_binding.v1.json" + materialization.write_text("{}", encoding="utf-8") + input_binding.write_text("{}", encoding="utf-8") + manifest = { + "schema": "ecc.workspace.candidate_workspace.v1", + "schema_version": 1, + "candidate_id": candidate_id, + "candidate_root_ref": candidate_ref, + "terminal_state": "failed", + "target_step": "place", + "end_step": "Harden", + "execution_scope": "full_flow", + "candidate_flow_sha256": sha256_path(flow), + "candidate_state_sha256": _workspace_state_sha256(candidate), + "artifacts": { + "candidate_materialization": { + "ref": "analysis/candidate_materialization.v1.json", + "sha256": sha256_path(materialization), + }, + "candidate_input_binding": { + "ref": "analysis/candidate_input_binding.v1.json", + "sha256": sha256_path(input_binding), + }, + }, + } + _validate_candidate_resume_manifest(tmp_path, candidate, candidate_ref, manifest) + + with pytest.raises(RuntimeApiError, match="manifest binding"): + _validate_candidate_resume_manifest( + tmp_path, candidate, candidate_ref, {**manifest, "terminal_state": "succeeded"} + ) + + input_binding.unlink() + with pytest.raises(RuntimeApiError, match="missing or unsafe"): + _validate_candidate_resume_manifest(tmp_path, candidate, candidate_ref, manifest) + input_binding.write_text("{}", encoding="utf-8") + + config.write_text('{"random_seed": 18}', encoding="utf-8") + with pytest.raises(RuntimeApiError, match="manifest binding"): + _validate_candidate_resume_manifest(tmp_path, candidate, candidate_ref, manifest) + + +def test_candidate_resume_restores_drifted_target_config_before_strict_validation( + monkeypatch, tmp_path +) -> None: + config = tmp_path / "config" / "dreamplace.json" + config.parent.mkdir() + config.write_text('{"random_seed": 17, "target_density": 0.5}', encoding="utf-8") + workspace = SimpleNamespace( + directory=tmp_path, + config={"dreamplace": config}, + flow=SimpleNamespace(data={"steps": [{"name": "place", "tool": "dreamplace"}]}), + ) + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.6}], + "candidate-1", + ) + config.write_text('{"random_seed": 17, "target_density": 0.9}', encoding="utf-8") + monkeypatch.setattr("agent.candidate_resume._reapply_candidate_input", lambda *_args: None) + + with pytest.raises(RuntimeApiError, match="seed binding"): + _validate_candidate_resume_binding( + workspace, + SimpleNamespace(), + {"target_step": "place", "artifacts": {}}, + CandidateResumeRequest( + workspace_id="workspace-1", + candidate_id="candidate-1", + idempotency_key="episode-1.resume-invalid", + context_sha256=CONTEXT_SHA256, + seed=18, + ), + ) + assert json.loads(config.read_text(encoding="utf-8"))["target_density"] == 0.9 + + patch = _validate_candidate_resume_binding( + workspace, + SimpleNamespace(), + {"target_step": "place", "artifacts": {}}, + CandidateResumeRequest( + workspace_id="workspace-1", + candidate_id="candidate-1", + idempotency_key="episode-1.resume-1", + context_sha256=CONTEXT_SHA256, + seed=17, + ), + ) + + assert patch == [{"knob_id": "place.target_density", "value": 0.6}] + assert json.loads(config.read_text(encoding="utf-8"))["target_density"] == 0.6 + + +class _EccApi: + def __init__(self, workspace): + self.session = SimpleNamespace(workspace=workspace, db_handle=None) + self.events = [] + self.operations = RuntimeOperationManager(self.events.append) + + def _get_session(self, workspace_id): + assert workspace_id == "workspace-1" + return self.session + + def _with_session_mutation_lock(self, workspace_id, operation): + assert workspace_id == "workspace-1" + return operation(self.session) + + def _close_transient_flow_db(self, _flow): + return None + + +class _Flow: + def __init__(self, workspace, workspace_steps): + self.workspace = workspace + self._workspace_steps = workspace_steps + self.workspace_steps = () + self.initialize_config = None + + def create_step_workspaces(self, *, initialize_config=True): + self.workspace_steps = self._workspace_steps + self.initialize_config = initialize_config + + def get_step(self, name, tool): + return next( + ( + step + for step in self.workspace.flow.data["steps"] + if step["name"] == name and step["tool"] == tool + ), + None, + ) + + def save(self): + self.workspace.flow.path.write_text(json.dumps(self.workspace.flow.data), encoding="utf-8") + return True + + +def _wait_for_terminal(operations, operation_id): + deadline = threading.Event() + for _ in range(100): + status = operations.operation_status(operation_id) + if status["state"] in {"succeeded", "failed", "cancelled"}: + assert status["state"] == "succeeded" + return status + deadline.wait(0.01) + raise AssertionError("candidate operation did not reach a terminal state") diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 04176a3d3..4957cc01c 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -5,7 +5,7 @@ import pytest from agent.methods import agent_method_names -from agent.requests import CandidateRerunRequest, parse_agent_request_model +from agent.requests import CandidateRerunRequest, CandidateResumeRequest, parse_agent_request_model from agent.server import AgentRuntimeServer from chipcompiler.runtime.requests import RequestValidationError from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame @@ -20,6 +20,7 @@ def test_agent_methods_keep_the_original_rpc_names(): "candidate.bind_input", "candidate.materialize", "candidate.rerun", + "candidate.resume", ) @@ -76,6 +77,42 @@ def test_candidate_rerun_request_requires_context_hash(): ) +def test_candidate_resume_request_accepts_only_execution_binding_fields(): + request = parse_agent_request_model( + CandidateResumeRequest, + { + "workspaceId": "workspace-1", + "candidateId": "candidate-1", + "idempotencyKey": "episode-1.resume-1", + "contextSha256": CONTEXT_SHA256, + "seed": 17, + }, + ) + + assert request == CandidateResumeRequest( + workspace_id="workspace-1", + candidate_id="candidate-1", + idempotency_key="episode-1.resume-1", + context_sha256=CONTEXT_SHA256, + seed=17, + ) + + +@pytest.mark.parametrize("extra", ["targetStep", "path", "patch", "command"]) +def test_candidate_resume_request_rejects_execution_authority_fields(extra): + params = { + "workspaceId": "workspace-1", + "candidateId": "candidate-1", + "idempotencyKey": "episode-1.resume-1", + "contextSha256": CONTEXT_SHA256, + "seed": 17, + extra: "untrusted", + } + + with pytest.raises(RequestValidationError, match="unknown field"): + parse_agent_request_model(CandidateResumeRequest, params) + + def test_candidate_rerun_request_requires_seed(): with pytest.raises(RequestValidationError, match="missing required field: seed"): parse_agent_request_model( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 5188d04cf..cb94b6566 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -22,6 +22,7 @@ export_candidate_capabilities, materialize_candidate_config, reapply_candidate_input_binding, + reapply_materialized_candidate_config, validate_candidate_step_contract, ) from .data.candidate_artifacts import sha256_path, validate_candidate_id, write_json_atomic @@ -35,6 +36,7 @@ CandidateBindInputRequest, CandidateMaterializeRequest, CandidateRerunRequest, + CandidateResumeRequest, WorkspaceExtractFoundationRequest, ) @@ -135,6 +137,11 @@ def candidate_rerun(self, request: CandidateRerunRequest) -> dict: except RuntimeOperationConflict as exc: raise RuntimeApiError("command_failed", str(exc)) from exc + def candidate_resume(self, request: CandidateResumeRequest) -> dict: + from .candidate_resume import candidate_resume + + return candidate_resume(self, request) + def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: candidate_workspace, candidate_root_ref, parent = _create_candidate_workspace( self.ecc_api, @@ -585,6 +592,7 @@ def _candidate_rerun_result( evidence_error = None if materialization_path.is_file(): try: + reapply_materialized_candidate_config(workspace, request.target_step) parameter_receipt = _candidate_parameter_receipt( workspace, request, From f95a39d26b090760769d29902ef9a5cb781400d5 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 2 Sep 2026 12:45:00 +0800 Subject: [PATCH 51/90] fix: report cell padding in dbu --- chipcompiler/tools/ecc_dreamplace/module.py | 5 +++++ .../test_parameter_runtime_report.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 0d11c1cde..9a47cb7b1 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -280,6 +280,11 @@ def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> if knob_id == "place.cell_padding_x": placedb = getattr(engine, "placedb", None) effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) + site_width_dbu = _scalar_value(getattr(placedb, "origin_site_width", None)) + if effective is not None and site_width_dbu is not None and site_width_dbu > 0: + effective = round(effective * site_width_dbu) + else: + effective = None movable = getattr(placedb, "num_movable_nodes", None) return { "requested_padding_dbu": requested, diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 547216e9e..4bedcf458 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -17,11 +17,15 @@ def item(self): return self.value -def _engine(*, target_density=None, cell_padding_x=None): +def _engine(*, target_density=None, cell_padding_x=None, origin_site_width=None): data_collections = SimpleNamespace(target_density=_Scalar(target_density)) return SimpleNamespace( placer=SimpleNamespace(data_collections=data_collections), - placedb=SimpleNamespace(cell_padding_x=cell_padding_x, num_movable_nodes=12), + placedb=SimpleNamespace( + cell_padding_x=cell_padding_x, + num_movable_nodes=12, + origin_site_width=origin_site_width, + ), ) @@ -182,16 +186,16 @@ def test_runtime_report_preserves_consumed_cell_padding_after_restore(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(cell_padding_x=0), - engine=_engine(target_density=0.8, cell_padding_x=200), + engine=_engine(target_density=0.8, cell_padding_x=2, origin_site_width=200), ppa={"iteration": 3}, engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_initial"] == {"unit": "dbu", "value": 200} + assert report["effective_initial"] == {"unit": "dbu", "value": 400} assert report["activation"]["status"] == "used" assert report["consumer_observation"] == { - "effective_padding_dbu": 200, + "effective_padding_dbu": 400, "evidence_complete": True, "movable_node_count": 12, "placement_iteration_count": 3, From 61296f5166fdc51960a84af046e2b0b33e44413d Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 2 Sep 2026 12:47:34 +0800 Subject: [PATCH 52/90] revert: defer cell padding evidence hash update --- chipcompiler/tools/ecc_dreamplace/module.py | 5 ----- .../test_parameter_runtime_report.py | 14 +++++--------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 9a47cb7b1..0d11c1cde 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -280,11 +280,6 @@ def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> if knob_id == "place.cell_padding_x": placedb = getattr(engine, "placedb", None) effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) - site_width_dbu = _scalar_value(getattr(placedb, "origin_site_width", None)) - if effective is not None and site_width_dbu is not None and site_width_dbu > 0: - effective = round(effective * site_width_dbu) - else: - effective = None movable = getattr(placedb, "num_movable_nodes", None) return { "requested_padding_dbu": requested, diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 4bedcf458..547216e9e 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -17,15 +17,11 @@ def item(self): return self.value -def _engine(*, target_density=None, cell_padding_x=None, origin_site_width=None): +def _engine(*, target_density=None, cell_padding_x=None): data_collections = SimpleNamespace(target_density=_Scalar(target_density)) return SimpleNamespace( placer=SimpleNamespace(data_collections=data_collections), - placedb=SimpleNamespace( - cell_padding_x=cell_padding_x, - num_movable_nodes=12, - origin_site_width=origin_site_width, - ), + placedb=SimpleNamespace(cell_padding_x=cell_padding_x, num_movable_nodes=12), ) @@ -186,16 +182,16 @@ def test_runtime_report_preserves_consumed_cell_padding_after_restore(tmp_path): _write_parameter_runtime_report( SimpleNamespace(directory=tmp_path), SimpleNamespace(cell_padding_x=0), - engine=_engine(target_density=0.8, cell_padding_x=2, origin_site_width=200), + engine=_engine(target_density=0.8, cell_padding_x=200), ppa={"iteration": 3}, engine_succeeded=True, ) report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_initial"] == {"unit": "dbu", "value": 400} + assert report["effective_initial"] == {"unit": "dbu", "value": 200} assert report["activation"]["status"] == "used" assert report["consumer_observation"] == { - "effective_padding_dbu": 400, + "effective_padding_dbu": 200, "evidence_complete": True, "movable_node_count": 12, "placement_iteration_count": 3, From 5eca311c6ed2822762fbfeb1ffc61fe4acc5d9cc Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 2 Sep 2026 14:45:14 +0800 Subject: [PATCH 53/90] fix: align flow agent with cts fanout --- agent/candidate_resume.py | 2 +- agent/test/test_candidate_resume.py | 18 +++++++ agent/test/test_workspace_api.py | 15 ++++-- agent/workspace_api.py | 8 +-- chipcompiler/tools/ecc/runner.py | 63 ++++++++++++++++++++++- test/tools/ecc/test_cts_runtime_report.py | 61 ++++++++++++++++++++++ test/tools/ecc/test_runner.py | 27 +++++++++- 7 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 test/tools/ecc/test_cts_runtime_report.py diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py index 4da6ef7fb..b0f3d28ec 100644 --- a/agent/candidate_resume.py +++ b/agent/candidate_resume.py @@ -361,7 +361,7 @@ def _candidate_resume_config_backups(workspace) -> dict[Path, bytes]: relatives = ( "home/parameters.json", "config/floorplan_ecc.json", - "config/fixfanout_ecc.json", + "config/cts_ecc.json", "config/dreamplace_ecc.json", "config/dreamplace.json", ) diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py index bfa1ac25b..9528542a8 100644 --- a/agent/test/test_candidate_resume.py +++ b/agent/test/test_candidate_resume.py @@ -38,6 +38,24 @@ def test_candidate_resume_slice_starts_at_first_non_success_step() -> None: assert [step.name for step in resumed] == ["CTS", "Harden"] +def test_workspace_state_hash_tracks_cts_not_retired_fixfanout(tmp_path) -> None: + home = tmp_path / "home" + config = tmp_path / "config" + home.mkdir() + config.mkdir() + (home / "flow.json").write_text('{"steps": []}', encoding="utf-8") + (config / "cts_ecc.json").write_text('{"max_fanout": 32}', encoding="utf-8") + retired = config / "fixfanout_ecc.json" + retired.write_text('{"max_fanout": 20}', encoding="utf-8") + + initial = _workspace_state_sha256(tmp_path) + retired.write_text('{"max_fanout": 99}', encoding="utf-8") + assert _workspace_state_sha256(tmp_path) == initial + + (config / "cts_ecc.json").write_text('{"max_fanout": 48}', encoding="utf-8") + assert _workspace_state_sha256(tmp_path) != initial + + def test_candidate_resume_runs_in_place_and_preserves_successful_target_artifacts( monkeypatch, tmp_path ) -> None: diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index d2ca68d02..a7d489fa3 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -39,14 +39,23 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): "target_step,expected_first", [ ("Floorplan", "Floorplan"), - ("fixFanout", "fixFanout"), ("place", "place"), + ("Timing optimization", "Timing optimization"), + ("CTS", "CTS"), ], ) def test_candidate_rerun_slice_starts_at_the_modified_stage( target_step: str, expected_first: str ) -> None: - names = ("Synthesis", "Floorplan", "fixFanout", "place", "CTS", "Harden") + names = ( + "Synthesis", + "Floorplan", + "place", + "CTS", + "legalization", + "Timing optimization", + "Harden", + ) flow = SimpleNamespace(workspace_steps=tuple(SimpleNamespace(name=name) for name in names)) steps = _candidate_rerun_steps(flow, target_step, "Harden", "full_flow") @@ -519,7 +528,7 @@ def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, (analysis / name).write_text('{"stale": true}', encoding="utf-8") flow = SimpleNamespace( workspace=SimpleNamespace( - flow=SimpleNamespace(data={"steps": [{"name": "fixFanout"}, {"name": "place"}]}) + flow=SimpleNamespace(data={"steps": [{"name": "Floorplan"}, {"name": "place"}]}) ) ) request = CandidateRerunRequest( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index cb94b6566..e853a2e4f 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -353,7 +353,7 @@ def _workspace_state_sha256(root: Path) -> str: "home/flow.json", "home/parameters.json", "config/floorplan_ecc.json", - "config/fixfanout_ecc.json", + "config/cts_ecc.json", "config/dreamplace_ecc.json", "config/dreamplace.json", ) @@ -664,8 +664,8 @@ def _candidate_parameter_receipt( tool_name = ( "ECC-Floorplan" if knob_id.startswith("floorplan.") - else "ECC-fixFanout" - if knob_id == "synth.max_fanout" + else "ECC-CTS" + if knob_id == "cts.max_fanout" else "DREAMPlace" ) runtime_report_path = ( @@ -841,7 +841,7 @@ def _remove_stale_parameter_receipts(workspace_root: Path) -> None: _RUNTIME_CONSUMERS_BY_KNOB = { "floorplan.core_util": {"ifp.die_builder.die_utilization"}, "floorplan.aspect_ratio": {"ifp.die_builder.die_aspect_ratio"}, - "synth.max_fanout": {"fixfanout.threshold_compare"}, + "cts.max_fanout": {"icts.synthesis.topology.max_fanout"}, "place.target_density": {"dreamplace.density_objective"}, "place.target_overflow": {"dreamplace.overflow_predicate"}, "place.cell_padding_x": {"dreamplace.cell_size_expansion"}, diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 7930a9ebd..8e152e9c5 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -551,10 +551,16 @@ def run_cts(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No if ecc_module is not None: sub_flow.update_step(step_name=EccSubFlowEnum.load_data.value, state=StateEnum.Success) - ecc_module.run_cts( - config=workspace.config.get(f"{StepEnum.CTS.value}", ""), + config_path = workspace.config.get(f"{StepEnum.CTS.value}", "") + engine_succeeded = ecc_module.run_cts( + config=config_path, output=(step.data.steps or {}).get(StepEnum.CTS.value, ""), ) + _write_cts_parameter_runtime_report( + workspace, config_path, engine_succeeded=bool(engine_succeeded) + ) + if not engine_succeeded: + return False ecc_module.report_cts(output=(step.data.steps or {}).get(StepEnum.CTS.value, "")) @@ -575,6 +581,59 @@ def run_cts(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No return reslut +def _write_cts_parameter_runtime_report( + workspace: Workspace, + config_path: str | Path, + *, + engine_succeeded: bool, +) -> None: + """Record CTS config effectiveness without claiming unobserved activation.""" + workspace_dir = getattr(workspace, "directory", None) + if workspace_dir is None: + return + materialization_path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" + if not materialization_path.is_file(): + return + try: + materialization = json.loads(materialization_path.read_text(encoding="utf-8")) + patch = next( + item for item in materialization["patch"] if item.get("knob_id") == "cts.max_fanout" + ) + value = json.loads(Path(config_path).read_text(encoding="utf-8"))["max_fanout"] + except (OSError, ValueError, KeyError, TypeError, StopIteration): + return + + requested = patch.get("value") + matches_request = type(value) is int and value == requested + effective = value if engine_succeeded else None + report = { + "knob_id": "cts.max_fanout", + "requested_value": requested, + "tool": { + "name": "ECC-CTS", + "revision": "ecc.cts.parameter_runtime_report.v1", + "source_sha256": _runner_source_sha256(), + }, + "application_status": ("applied" if engine_succeeded and matches_request else "unknown"), + "effective_initial": {"value": effective, "unit": "fanout"}, + "effective_final": {"value": effective, "unit": "fanout"}, + "activation": {"status": "unknown", "consumers": []}, + "consumer_observation": { + "config_value": value, + "engine_succeeded": engine_succeeded, + "activation_evidence_complete": False, + }, + "transitions": [], + } + output_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") + temporary.write_text( + json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" + ) + os.replace(temporary, output_path) + + def run_routing( workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | None = None ) -> bool: diff --git a/test/tools/ecc/test_cts_runtime_report.py b/test/tools/ecc/test_cts_runtime_report.py new file mode 100644 index 000000000..b654f0186 --- /dev/null +++ b/test/tools/ecc/test_cts_runtime_report.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +from chipcompiler.tools.ecc.runner import _write_cts_parameter_runtime_report + + +def _write_candidate(tmp_path: Path, value: int) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "cts.max_fanout", "value": value}]}), + encoding="utf-8", + ) + + +def test_cts_runtime_report_records_effective_value_without_claiming_activation(tmp_path): + _write_candidate(tmp_path, 48) + config = tmp_path / "config" / "cts_ecc.json" + config.parent.mkdir() + config.write_text('{"max_fanout": 48}', encoding="utf-8") + + _write_cts_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), config, engine_succeeded=True + ) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + source = Path(_write_cts_parameter_runtime_report.__code__.co_filename) + assert report["tool"] == { + "name": "ECC-CTS", + "revision": "ecc.cts.parameter_runtime_report.v1", + "source_sha256": "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest(), + } + assert report["knob_id"] == "cts.max_fanout" + assert report["requested_value"] == 48 + assert report["application_status"] == "applied" + assert report["effective_final"] == {"value": 48, "unit": "fanout"} + assert report["activation"] == {"status": "unknown", "consumers": []} + assert report["consumer_observation"] == { + "config_value": 48, + "engine_succeeded": True, + "activation_evidence_complete": False, + } + + +def test_cts_runtime_report_rejects_mismatched_effective_value(tmp_path): + _write_candidate(tmp_path, 48) + config = tmp_path / "config" / "cts_ecc.json" + config.parent.mkdir() + config.write_text('{"max_fanout": 32}', encoding="utf-8") + + _write_cts_parameter_runtime_report( + SimpleNamespace(directory=tmp_path), config, engine_succeeded=True + ) + + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["application_status"] == "unknown" + assert report["effective_final"] == {"value": 32, "unit": "fanout"} diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index fdf681dcd..a007ac75d 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -98,12 +98,14 @@ def update_step(self, **kwargs): class FakeCtsModule: - def __init__(self, timing_quality): + def __init__(self, timing_quality, *, succeeded=True): self.calls = [] self.timing_quality = timing_quality + self.succeeded = succeeded def run_cts(self, **kwargs): self.calls.append(("run_cts", kwargs)) + return self.succeeded def update_step_paths(self, **kwargs): self.calls.append(("update_step_paths", kwargs)) @@ -396,6 +398,29 @@ def test_run_cts_merges_structured_timing_into_step_feature(tmp_path, monkeypatc ] +def test_run_cts_stops_when_native_flow_fails(tmp_path, monkeypatch): + config = tmp_path / "config" / "cts.json" + config.parent.mkdir() + config.write_text('{"max_fanout": 48}', encoding="utf-8") + workspace = Workspace( + directory=tmp_path, + design=OriginDesign(name="gcd", top_module="gcd"), + config={StepEnum.CTS.value: config}, + ) + step = build_step( + workspace=workspace, + step_name=StepEnum.CTS.value, + input_def=tmp_path / "input.def", + input_verilog=tmp_path / "input.v", + ) + build_step_space(step) + module = FakeCtsModule({}, succeeded=False) + monkeypatch.setattr(ecc_runner, "EccSubFlow", FakeSubFlow) + + assert ecc_runner.run_cts(workspace, step, module) is False + assert [call[0] for call in module.calls] == ["update_step_paths", "run_cts"] + + def test_run_sta_without_spef_reads_netlist_and_writes_to_step_report_and_feature( tmp_path, monkeypatch ): From 4760585e3d334d89fc2aea1cf4a37e1007011a74 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 2 Sep 2026 16:32:10 +0800 Subject: [PATCH 54/90] test: remove retired fanout fixture --- agent/test/test_candidate_resume.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py index 9528542a8..c8921d389 100644 --- a/agent/test/test_candidate_resume.py +++ b/agent/test/test_candidate_resume.py @@ -38,20 +38,15 @@ def test_candidate_resume_slice_starts_at_first_non_success_step() -> None: assert [step.name for step in resumed] == ["CTS", "Harden"] -def test_workspace_state_hash_tracks_cts_not_retired_fixfanout(tmp_path) -> None: +def test_workspace_state_hash_tracks_cts_config(tmp_path) -> None: home = tmp_path / "home" config = tmp_path / "config" home.mkdir() config.mkdir() (home / "flow.json").write_text('{"steps": []}', encoding="utf-8") (config / "cts_ecc.json").write_text('{"max_fanout": 32}', encoding="utf-8") - retired = config / "fixfanout_ecc.json" - retired.write_text('{"max_fanout": 20}', encoding="utf-8") initial = _workspace_state_sha256(tmp_path) - retired.write_text('{"max_fanout": 99}', encoding="utf-8") - assert _workspace_state_sha256(tmp_path) == initial - (config / "cts_ecc.json").write_text('{"max_fanout": 48}', encoding="utf-8") assert _workspace_state_sha256(tmp_path) != initial From def19456c65db964c94b7d92ed80555b18b6df01 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 3 Sep 2026 00:21:19 +0800 Subject: [PATCH 55/90] fix: resolve Sizer from runtime root --- chipcompiler/tools/ecc_sizer/utility.py | 22 +++++++++++++++++++++- test/tools/ecc_sizer/test_module.py | 16 ++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/chipcompiler/tools/ecc_sizer/utility.py b/chipcompiler/tools/ecc_sizer/utility.py index b0a1b9d9b..3f6c75e2b 100644 --- a/chipcompiler/tools/ecc_sizer/utility.py +++ b/chipcompiler/tools/ecc_sizer/utility.py @@ -46,8 +46,28 @@ def get_sizer_root() -> Path | None: def get_sizer_command() -> list[str]: + candidates: list[Path] = [] + override = os.environ.get("CHIPCOMPILER_ECC_SIZER_ROOT", "").strip() + if override: + root = Path(override).expanduser() + candidates.extend( + root / relative + for relative in ( + Path("bin") / "Sizer", + Path("build") / "src" / "Sizer", + Path("build") / "Sizer", + Path("Sizer"), + ) + ) + sizer = shutil.which("Sizer") - return [str(Path(sizer).resolve())] if sizer else [] + if sizer: + candidates.append(Path(sizer)) + + for candidate in candidates: + if candidate.is_file() and os.access(candidate, os.X_OK): + return [str(candidate.resolve())] + return [] def is_eda_exist() -> bool: diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index 7fdc3b4cb..49e06884a 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -379,6 +379,22 @@ def test_sizer_command_resolves_from_path_only(tmp_path, monkeypatch): assert is_eda_exist() is False +def test_sizer_command_resolves_from_runtime_root_without_path(tmp_path, monkeypatch): + from chipcompiler.tools.ecc_sizer.utility import get_sizer_command, is_eda_exist + + runtime_root = _sizer_runtime(tmp_path) + sizer = runtime_root / "bin" / "Sizer" + sizer.parent.mkdir(parents=True) + sizer.write_text("#!/bin/sh\n", encoding="utf-8") + sizer.chmod(0o755) + + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + + assert get_sizer_command() == [str(sizer.resolve())] + assert is_eda_exist() is True + + def test_sizer_runtime_root_resolves_from_path_binary(tmp_path, monkeypatch): from chipcompiler.tools.ecc_sizer.utility import find_sizer_root, get_sizer_root From 68ec999d754cd9afd3e6578cc70cf1b4455788ac Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 3 Sep 2026 04:25:18 +0800 Subject: [PATCH 56/90] fix: isolate Sizer loader environment --- chipcompiler/tools/ecc_sizer/runner.py | 8 +++++++- chipcompiler/tools/ecc_sizer/utility.py | 7 +++++++ test/tools/ecc_sizer/_sizer_helpers.py | 4 ++-- test/tools/ecc_sizer/test_runner.py | 18 +++++++++++++----- test/tools/ecc_sizer/test_runner_cleanup.py | 6 +++--- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index e495fc1ac..7097bbb2d 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -11,7 +11,12 @@ from .builder import sizer_staging_def, sizer_staging_verilog from .subflow import SizerSubFlow, SizerSubFlowEnum -from .utility import get_sizer_command, is_eda_exist, is_sizer_runtime_exist +from .utility import ( + get_sizer_command, + get_sizer_subprocess_env, + is_eda_exist, + is_sizer_runtime_exist, +) logger = logging.getLogger(__name__) @@ -123,6 +128,7 @@ def run_step( stdout=None, stderr=subprocess.STDOUT, check=False, + env=get_sizer_subprocess_env(), ) if result.returncode != 0 or not _has_staging_outputs(step): diff --git a/chipcompiler/tools/ecc_sizer/utility.py b/chipcompiler/tools/ecc_sizer/utility.py index 3f6c75e2b..e8b3a46a9 100644 --- a/chipcompiler/tools/ecc_sizer/utility.py +++ b/chipcompiler/tools/ecc_sizer/utility.py @@ -5,6 +5,13 @@ _SIZER_RUNTIME_SENTINEL = Path("src") / "sizer_os.tcl" +def get_sizer_subprocess_env() -> dict[str, str]: + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + env.pop("LD_PRELOAD", None) + return env + + def _is_sizer_root(path: Path) -> bool: return (path / _SIZER_RUNTIME_SENTINEL).is_file() diff --git a/test/tools/ecc_sizer/_sizer_helpers.py b/test/tools/ecc_sizer/_sizer_helpers.py index 522a44f15..3b0b24218 100644 --- a/test/tools/ecc_sizer/_sizer_helpers.py +++ b/test/tools/ecc_sizer/_sizer_helpers.py @@ -72,8 +72,8 @@ def _write_staging(step: EccStep) -> None: def _fake_sizer_run(step: EccStep): - def fake_run(command, cwd, stdout, stderr, check): - del command, cwd, stdout, stderr, check + def fake_run(command, cwd, stdout, stderr, check, env): + del command, cwd, stdout, stderr, check, env _write_staging(step) return SimpleNamespace(returncode=0) diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 4981bb79b..6a37fe13a 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -31,8 +31,8 @@ def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, mon calls = [] - def fake_run(command, cwd, stdout, stderr, check): - calls.append((command, cwd, stdout, stderr, check)) + def fake_run(command, cwd, stdout, stderr, check, env): + calls.append((command, cwd, stdout, stderr, check, env)) _write_staging(step) return SimpleNamespace(returncode=0) @@ -42,6 +42,9 @@ def fake_run(command, cwd, stdout, stderr, check): monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setenv("LD_LIBRARY_PATH", "/app/resources/binaries/_internal") + monkeypatch.setenv("LD_PRELOAD", "/bad/preload.so") + monkeypatch.setenv("KEEP_ME", "kept") assert ( sizer_runner.run_step( @@ -72,6 +75,11 @@ def fake_run(command, cwd, stdout, stderr, check): None, subprocess.STDOUT, False, + { + key: value + for key, value in os.environ.items() + if key not in {"LD_LIBRARY_PATH", "LD_PRELOAD"} + }, ) ] @@ -159,7 +167,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), ) assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete @@ -185,8 +193,8 @@ def test_sizer_runner_inherits_captured_stdio_instead_of_truncating_step_log( Path(step.log.file).write_text("preface\n", encoding="utf-8") seen = {} - def fake_run(command, cwd, stdout, stderr, check): - del command, cwd, check + def fake_run(command, cwd, stdout, stderr, check, env): + del command, cwd, check, env seen["stdout"] = stdout seen["stderr"] = stderr return SimpleNamespace(returncode=1) diff --git a/test/tools/ecc_sizer/test_runner_cleanup.py b/test/tools/ecc_sizer/test_runner_cleanup.py index 6308ba0ac..872ce4e8e 100644 --- a/test/tools/ecc_sizer/test_runner_cleanup.py +++ b/test/tools/ecc_sizer/test_runner_cleanup.py @@ -206,7 +206,7 @@ def record_legalize(*args, **kwargs): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), ) with pytest.raises(OSError, match="cannot unlink staging"): @@ -242,7 +242,7 @@ def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), ) assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete states = _subflow_states(step) @@ -283,7 +283,7 @@ def fake_legalize(*args, **kwargs): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), ) monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) From 293374282f238ea51724ef2bbc9afa60d8f70e91 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 3 Sep 2026 10:47:52 +0800 Subject: [PATCH 57/90] fix: fingerprint runtime workspace inputs --- agent/test/test_parameter_receipt_artifacts.py | 16 +++++++--------- agent/workspace_api.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 67c4c0c03..e380d4502 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -60,10 +60,10 @@ def _materialized_workspace( encoding="utf-8", ) origin = tmp_path / "origin" - (origin / "rtl").mkdir(parents=True) - (origin / "rtl" / "top.v").write_text("module top; endmodule\n", encoding="utf-8") + origin.mkdir() + (origin / "top.v").write_text("module top; endmodule\n", encoding="utf-8") (origin / "constraints.sdc").write_text("create_clock clk\n", encoding="utf-8") - (origin / "filelist.f").write_text("rtl/top.v\n", encoding="utf-8") + (origin / "filelist").write_text("top.v\n", encoding="utf-8") home = tmp_path / "home" home.mkdir() (home / "parameters.json").write_text( @@ -140,9 +140,9 @@ def test_parameter_receipt_context_aggregates_all_rtl_and_sdc_files(tmp_path: Pa written=0.85, ) origin = tmp_path / "origin" - (origin / "rtl" / "worker.v").write_text("module worker; endmodule\n", encoding="utf-8") + (origin / "worker.v").write_text("module worker; endmodule\n", encoding="utf-8") (origin / "timing.sdc").write_text("set_input_delay 1 clk\n", encoding="utf-8") - (origin / "filelist.f").write_text("rtl/top.v\nrtl/worker.v\n", encoding="utf-8") + (origin / "filelist").write_text("top.v\nworker.v\n", encoding="utf-8") request = SimpleNamespace( candidate_id="candidate-multifile", target_step="place", @@ -152,13 +152,11 @@ def test_parameter_receipt_context_aggregates_all_rtl_and_sdc_files(tmp_path: Pa context = _parameter_receipt_context(workspace, request, HASH) - rtl_sha256 = _stable_hash( - {"files": [sha256_path(path) for path in sorted((origin / "rtl").glob("*"))]} - ) + rtl_sha256 = _stable_hash({"files": [sha256_path(path) for path in sorted(origin.glob("*.v"))]}) sdc_sha256 = _stable_hash( {"files": [sha256_path(path) for path in sorted(origin.glob("*.sdc"))]} ) - filelist_sha256 = sha256_path(origin / "filelist.f") + filelist_sha256 = sha256_path(origin / "filelist") assert context["rtl_sha256"] == rtl_sha256 assert context["sdc_sha256"] == sdc_sha256 assert context["design_sha256"] == _stable_hash( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index e853a2e4f..6a9c1dc0a 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -733,7 +733,14 @@ def _candidate_parameter_receipt( def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> dict[str, object]: root = Path(workspace.directory) origin = root / "origin" - rtl_files = sorted(path for path in (origin / "rtl").glob("*") if path.is_file()) + rtl_files = sorted( + path + for path in origin.rglob("*") + if path.is_file() + and path.name.casefold() + .removesuffix(".gz") + .endswith((".v", ".sv", ".vh", ".svh", ".vhd", ".vhdl")) + ) sdc_files = sorted(origin.glob("*.sdc")) if not rtl_files or not sdc_files: raise RuntimeApiError("command_failed", "candidate input fingerprints are unavailable") @@ -761,8 +768,11 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d raise RuntimeApiError("command_failed", "candidate PDK fingerprint is unavailable") from exc rtl_hashes = [f"sha256:{sha256(path.read_bytes()).hexdigest()}" for path in rtl_files] sdc_hashes = [f"sha256:{sha256(path.read_bytes()).hexdigest()}" for path in sdc_files] - filelist = origin / "filelist.f" - if not filelist.is_file(): + filelist = next( + (path for path in (origin / "filelist", origin / "filelist.f") if path.is_file()), + None, + ) + if filelist is None: raise RuntimeApiError("command_failed", "candidate filelist fingerprint is unavailable") filelist_sha256 = f"sha256:{sha256(filelist.read_bytes()).hexdigest()}" rtl_sha256 = rtl_hashes[0] if len(rtl_hashes) == 1 else _stable_hash({"files": rtl_hashes}) From e9d029cc7704bd1f56c0dfba1b5f01f6cbe13a7a Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 3 Sep 2026 12:23:15 +0800 Subject: [PATCH 58/90] fix: bind candidate receipts to parameter cards --- agent/candidate_resume.py | 9 +++++ agent/requests.py | 3 ++ agent/test/test_candidate_resume.py | 3 ++ .../test/test_parameter_receipt_artifacts.py | 8 ++++ agent/test/test_requests.py | 7 ++++ agent/test/test_workspace_api.py | 38 +++++++++++++++++++ agent/workspace_api.py | 6 +++ 7 files changed, 74 insertions(+) diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py index b0f3d28ec..4bd313a3c 100644 --- a/agent/candidate_resume.py +++ b/agent/candidate_resume.py @@ -148,6 +148,13 @@ def _validate_candidate_resume_request(request: CandidateResumeRequest) -> None: or re.fullmatch(r"sha256:[0-9a-f]{64}", request.context_sha256) is None ): raise RuntimeApiError("invalid_request", "candidate resume context_sha256 is invalid") + if ( + not isinstance(request.parameter_card_sha256, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", request.parameter_card_sha256) is None + ): + raise RuntimeApiError( + "invalid_request", "candidate resume parameter_card_sha256 is invalid" + ) if type(request.seed) is not int: raise RuntimeApiError("invalid_request", "candidate resume seed is invalid") @@ -268,6 +275,7 @@ def _candidate_resume_rerun_request( execution_scope="full_flow", idempotency_key=request.idempotency_key, context_sha256=request.context_sha256, + parameter_card_sha256=request.parameter_card_sha256, seed=request.seed, parent_candidate_root_ref=manifest["parent_candidate_root_ref"], ) @@ -348,6 +356,7 @@ def _candidate_resume_requested_patch( ) from exc if ( context.get("context_sha256") != request.context_sha256 + or context.get("parameter_card_sha256") != request.parameter_card_sha256 or context.get("seed") != request.seed or context.get("run_id") != request.candidate_id or context.get("stage") != target_step diff --git a/agent/requests.py b/agent/requests.py index 1fec8daf6..265078432 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -35,6 +35,7 @@ class CandidateRerunRequest: execution_scope: str idempotency_key: str context_sha256: str + parameter_card_sha256: str seed: int parent_candidate_root_ref: str | None = None @@ -45,6 +46,7 @@ class CandidateResumeRequest: candidate_id: str idempotency_key: str context_sha256: str + parameter_card_sha256: str seed: int @@ -57,6 +59,7 @@ class CandidateResumeRequest: "executionScope": "execution_scope", "idempotencyKey": "idempotency_key", "contextSha256": "context_sha256", + "parameterCardSha256": "parameter_card_sha256", "parentCandidateRootRef": "parent_candidate_root_ref", } diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py index c8921d389..098fc0eb5 100644 --- a/agent/test/test_candidate_resume.py +++ b/agent/test/test_candidate_resume.py @@ -120,6 +120,7 @@ def test_candidate_resume_runs_in_place_and_preserves_successful_target_artifact candidate_id=candidate_id, idempotency_key="episode-1.resume-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -219,6 +220,7 @@ def test_candidate_resume_restores_drifted_target_config_before_strict_validatio candidate_id="candidate-1", idempotency_key="episode-1.resume-invalid", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=18, ), ) @@ -233,6 +235,7 @@ def test_candidate_resume_restores_drifted_target_config_before_strict_validatio candidate_id="candidate-1", idempotency_key="episode-1.resume-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ), ) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index e380d4502..72f902880 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -107,6 +107,7 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) @@ -123,6 +124,7 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No assert json.loads(receipt_path.read_text(encoding="utf-8")) == receipt assert sha256_path(receipt_path) is not None assert receipt["tool"] == TOOL + assert receipt["context"]["parameter_card_sha256"] == HASH materialization_ref = receipt["materialization"] assert materialization_ref["target_step"] == "place" assert materialization_ref["config_ref"] == "config/dreamplace.json" @@ -181,6 +183,7 @@ def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monke target_step="place", patch=[{"knob_id": "place.cell_padding_x", "value": 1}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) monkeypatch.setattr( @@ -217,6 +220,7 @@ def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> No target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) @@ -289,6 +293,7 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.2}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) @@ -345,6 +350,7 @@ def test_candidate_parameter_receipt_rejects_runtime_report_for_another_knob( target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) @@ -378,6 +384,7 @@ def test_candidate_parameter_receipt_requires_parent_flow_sha256( target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) @@ -411,6 +418,7 @@ def test_candidate_parameter_receipt_rejects_stripped_unknown_ecc_revision( target_step="place", patch=[{"knob_id": "place.target_density", "value": 0.85}], context_sha256=HASH, + parameter_card_sha256=HASH, seed=17, ) monkeypatch.setattr("agent.workspace_api.chipcompiler.__version__", " unknown ") diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 4957cc01c..5781dac38 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -11,6 +11,7 @@ from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame CONTEXT_SHA256 = "sha256:" + "a" * 64 +PARAMETER_CARD_SHA256 = "sha256:" + "b" * 64 def test_agent_methods_keep_the_original_rpc_names(): @@ -42,6 +43,7 @@ def test_agent_request_normalizes_camel_case_fields(): "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, "seed": 17, "parentCandidateRootRef": ".agent/candidates/candidate-0", }, @@ -56,6 +58,7 @@ def test_agent_request_normalizes_camel_case_fields(): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=PARAMETER_CARD_SHA256, seed=17, parent_candidate_root_ref=".agent/candidates/candidate-0", ) @@ -85,6 +88,7 @@ def test_candidate_resume_request_accepts_only_execution_binding_fields(): "candidateId": "candidate-1", "idempotencyKey": "episode-1.resume-1", "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, "seed": 17, }, ) @@ -94,6 +98,7 @@ def test_candidate_resume_request_accepts_only_execution_binding_fields(): candidate_id="candidate-1", idempotency_key="episode-1.resume-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=PARAMETER_CARD_SHA256, seed=17, ) @@ -126,6 +131,7 @@ def test_candidate_rerun_request_requires_seed(): "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, }, ) @@ -168,6 +174,7 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): "executionScope": "full_flow", "idempotencyKey": "episode-1.intervention-1", "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, "seed": 17, }, } diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index a7d489fa3..6108829d9 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -204,6 +204,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -223,6 +224,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -313,6 +315,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-2", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, parent_candidate_root_ref=candidate_root_ref, ) @@ -355,6 +358,7 @@ def materialize(candidate_workspace, target, patch, candidate): execution_scope="full_flow", idempotency_key="episode-1.intervention-3", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, parent_candidate_root_ref=candidate_root_ref, ) @@ -478,6 +482,7 @@ def run_candidate_step(_flow, step, **_kwargs): execution_scope="full_flow", idempotency_key="episode-1.failed", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -540,6 +545,7 @@ def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) monkeypatch.setattr("agent.workspace_api.bind_candidate_input", lambda *_args: None) @@ -573,6 +579,7 @@ def test_candidate_rerun_rejects_multi_knob_patch_before_starting_an_operation(t execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -595,6 +602,32 @@ def test_candidate_rerun_rejects_invalid_context_hash_before_starting_an_operati execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256="sha256:invalid", + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + assert ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + + +def test_candidate_rerun_rejects_invalid_parameter_card_hash_before_starting_an_operation( + tmp_path, +): + ecc_api = _EccApi(SimpleNamespace(directory=tmp_path)) + api = FlowAgentRuntimeApi(ecc_api) + + with pytest.raises(RuntimeApiError, match="parameter_card_sha256"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256="sha256:invalid", seed=17, ) ) @@ -617,6 +650,7 @@ def test_candidate_rerun_rejects_non_harden_end_step_before_starting_an_operatio execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -639,6 +673,7 @@ def test_candidate_rerun_rejects_unsafe_candidate_id_before_starting_an_operatio execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -663,6 +698,7 @@ def test_candidate_rerun_rejects_unsafe_parent_candidate_ref_before_starting_an_ execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, parent_candidate_root_ref="../outside", ) @@ -688,6 +724,7 @@ def test_candidate_rerun_rejects_parent_workspace_symlinks(tmp_path): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) @@ -726,6 +763,7 @@ def fail_copy(*_args, **_kwargs): execution_scope="full_flow", idempotency_key="episode-1.intervention-1", context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, seed=17, ) ) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 6a9c1dc0a..2e2d20347 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -296,6 +296,11 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: or re.fullmatch(r"sha256:[0-9a-f]{64}", request.context_sha256) is None ): raise RuntimeApiError("invalid_request", "candidate rerun context_sha256 is invalid") + if ( + not isinstance(request.parameter_card_sha256, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", request.parameter_card_sha256) is None + ): + raise RuntimeApiError("invalid_request", "candidate rerun parameter_card_sha256 is invalid") if type(request.seed) is not int: raise RuntimeApiError("invalid_request", "candidate rerun seed is invalid") if request.parent_candidate_root_ref is not None: @@ -693,6 +698,7 @@ def _candidate_parameter_receipt( context = _parameter_receipt_context(workspace, request, parent_flow_sha256) context["tool_revision"] = tool["revision"] context["context_sha256"] = request.context_sha256 + context["parameter_card_sha256"] = request.parameter_card_sha256 requested_value = patch["value"] written_unit = unit if knob_id == "place.cell_padding_x": From 03e55946b080ac5356ddf8a273416074f056a38f Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 3 Sep 2026 19:38:50 +0800 Subject: [PATCH 59/90] fix: bind candidate snapshots to persisted configs --- agent/data/candidate_materialization.py | 16 +++++++++--- .../data/test_candidate_materialization.py | 25 ++++++++----------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 475612156..ecc810fc5 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -53,6 +53,13 @@ def materialize_candidate_config( configs, ) after_hashes = _write_configs(workspace, configs, config_paths) + for snapshot in snapshots: + config_key = snapshot["config_key"] + shutil.copyfile( + config_paths[config_key], + Path(workspace.directory) / snapshot["after_ref"], + ) + snapshot["after_sha256"] = after_hashes[config_key] receipt = _build_receipt( workspace, target_step, @@ -88,7 +95,7 @@ def reapply_materialized_candidate_config( shutil.copyfile(after_path, config_path) if config_key == "parameters" and hasattr(workspace, "parameters"): try: - workspace.parameters.data = read_json_object(config_path, "candidate parameters") + workspace.parameters.data = _load_parameters_config(config_path) except ValueError as error: raise CandidateMaterializationError(str(error)) from error _verify_materialized_config_hashes(workspace, receipt["configs"]) @@ -332,7 +339,10 @@ def _write_parameters_config(workspace: Any, path: Path, config: dict) -> Path: parameters.data["_flow"] = existing_flow if not save_parameter(parameters): raise ValueError(f"failed to write candidate config: {path}") - return workspace_config_path(workspace.directory) + target = workspace_config_path(workspace.directory) + if hasattr(workspace, "parameters"): + workspace.parameters.path = target + return target def _load_configs( @@ -354,7 +364,7 @@ def _load_configs( configs[knob.config_key] = read_json_object(path, "candidate base config") except ValueError as error: raise CandidateMaterializationError(str(error)) from error - before = sha256_bytes(canonical_json_bytes(configs[knob.config_key])) + before = sha256_path(path) if before is None: raise CandidateMaterializationError(f"missing candidate base config: {path}") before_hashes[knob.config_key] = before diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index b05da334b..2cd94dc73 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -376,23 +376,25 @@ def test_validated_receipt_requires_complete_one_to_one_config_snapshots(tmp_pat def test_reapply_keeps_in_memory_parameters_consistent(tmp_path): + from chipcompiler.data.parameter import load_parameter, save_parameter + workspace = _workspace(tmp_path) - workspace.parameters.data = _read_json(workspace.parameters.path) + workspace.parameters.data = load_parameter(workspace.parameters.path).data materialize_candidate_config( workspace, "Floorplan", [{"knob_id": "floorplan.core_util", "value": 0.7}], candidate_id="floorplan-candidate", ) - refreshed = _read_json(workspace.parameters.path) - refreshed["Core"]["Utilitization"] = 0.6 - _write_json(workspace.parameters.path, refreshed) - workspace.parameters.data = refreshed + refreshed = load_parameter(workspace.parameters.path) + refreshed.data["core"]["utilitization"] = 0.6 + assert save_parameter(refreshed) + workspace.parameters.data = refreshed.data reapply_materialized_candidate_config(workspace, "Floorplan") - assert workspace.parameters.data == _read_json(workspace.parameters.path) - assert workspace.parameters.data["Core"]["Utilitization"] == 0.7 + assert workspace.parameters.data == load_parameter(workspace.parameters.path).data + assert workspace.parameters.data["core"]["utilitization"] == 0.7 def test_reapply_keeps_receipt_when_tool_rewrites_equivalent_json(tmp_path): @@ -643,12 +645,7 @@ def test_materialize_floorplan_patch_preserves_the_canonical_core_tree(tmp_path) materialize_candidate_config( workspace, "Floorplan", - [ - {"knob_id": "floorplan.core_util", "value": 0.7}, - {"knob_id": "floorplan.aspect_ratio", "value": 1.1}, - {"knob_id": "floorplan.core_margin", "value": [3, 3]}, - {"knob_id": "floorplan.tap_distance", "value": 5}, - ], + [{"knob_id": "floorplan.core_util", "value": 0.7}], candidate_id="floorplan-candidate", ) @@ -656,7 +653,7 @@ def test_materialize_floorplan_patch_preserves_the_canonical_core_tree(tmp_path) reloaded = load_parameter(Path(workspace.parameters.path)).data assert "Core" not in reloaded - assert reloaded["core"] == {"utilitization": 0.7, "aspect_ratio": 1.1, "margin": [3, 3]} + assert reloaded["core"] == {"utilitization": 0.7, "aspect_ratio": 1.0, "margin": [2, 2]} assert "Core" not in workspace.parameters.data assert workspace.parameters.data["core"]["utilitization"] == 0.7 From 217997c5ef3bcca868e29e8556c3dd71d6c37a6f Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 4 Sep 2026 11:21:36 +0800 Subject: [PATCH 60/90] refactor: isolate ECC parameter runtime reports --- .../tools/ecc/parameter_runtime_report.py | 217 ++++++++++++++++++ chipcompiler/tools/ecc/runner.py | 210 +---------------- test/tools/ecc/test_cts_runtime_report.py | 2 +- .../ecc/test_floorplan_runtime_report.py | 4 +- 4 files changed, 225 insertions(+), 208 deletions(-) create mode 100644 chipcompiler/tools/ecc/parameter_runtime_report.py diff --git a/chipcompiler/tools/ecc/parameter_runtime_report.py b/chipcompiler/tools/ecc/parameter_runtime_report.py new file mode 100644 index 000000000..7216c842c --- /dev/null +++ b/chipcompiler/tools/ecc/parameter_runtime_report.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python + +import hashlib +import json +import os +import re +from math import isfinite +from pathlib import Path + +from chipcompiler.data import Workspace + +CTS_RUNTIME_REPORT_REVISION = "ecc.cts.parameter_runtime_report.v1" +FLOORPLAN_RUNTIME_REPORT_REVISION = "ecc.floorplan.parameter_runtime_report.v2" +_RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" + + +def _source_sha256() -> str: + return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def _candidate_patch(workspace_dir: Path, knob_ids: set[str]) -> dict | None: + path = workspace_dir / "analysis" / "candidate_materialization.v1.json" + if not path.is_file(): + return None + try: + materialization = json.loads(path.read_text(encoding="utf-8")) + return next(item for item in materialization["patch"] if item.get("knob_id") in knob_ids) + except (OSError, ValueError, KeyError, TypeError, StopIteration): + return None + + +def _write_runtime_report(workspace_dir: Path, report: dict) -> None: + output_path = workspace_dir / _RUNTIME_REPORT_REF + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") + temporary.write_text( + json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" + ) + os.replace(temporary, output_path) + + +def _write_cts_parameter_runtime_report( + workspace: Workspace, + config_path: str | Path, + *, + engine_succeeded: bool, +) -> None: + """Record CTS config effectiveness without claiming unobserved activation.""" + workspace_dir = getattr(workspace, "directory", None) + if workspace_dir is None: + return + workspace_dir = Path(workspace_dir) + patch = _candidate_patch(workspace_dir, {"cts.max_fanout"}) + if patch is None: + return + try: + value = json.loads(Path(config_path).read_text(encoding="utf-8"))["max_fanout"] + except (OSError, ValueError, KeyError, TypeError): + return + + requested = patch.get("value") + matches_request = type(value) is int and value == requested + effective = value if engine_succeeded else None + report = { + "knob_id": "cts.max_fanout", + "requested_value": requested, + "tool": { + "name": "ECC-CTS", + "revision": CTS_RUNTIME_REPORT_REVISION, + "source_sha256": _source_sha256(), + }, + "application_status": ("applied" if engine_succeeded and matches_request else "unknown"), + "effective_initial": {"value": effective, "unit": "fanout"}, + "effective_final": {"value": effective, "unit": "fanout"}, + "activation": {"status": "unknown", "consumers": []}, + "consumer_observation": { + "config_value": value, + "engine_succeeded": engine_succeeded, + "activation_evidence_complete": False, + }, + "transitions": [], + } + _write_runtime_report(workspace_dir, report) + + +def _write_floorplan_parameter_runtime_report( + workspace: Workspace, + config_path: str | Path, + *, + feature_path: str | Path | None = None, + report_path: str | Path | None = None, +) -> None: + """Record the candidate knob consumed by iFP's native die builder.""" + workspace_dir = getattr(workspace, "directory", None) + if workspace_dir is None: + return + workspace_dir = Path(workspace_dir) + patch = _candidate_patch(workspace_dir, {"floorplan.core_util", "floorplan.aspect_ratio"}) + if patch is None: + return + try: + floorplan = json.loads(Path(config_path).read_text(encoding="utf-8")) + die_builder = floorplan["die_builder"] + die_util = die_builder["die_util"] + except (OSError, ValueError, KeyError, TypeError): + return + + knob_id = patch["knob_id"] + field, consumer_id = { + "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), + "floorplan.aspect_ratio": ("aspect_ratio", "ifp.die_builder.die_aspect_ratio"), + }[knob_id] + value = die_util.get(field) + requested = patch.get("value") + mode = die_builder.get("mode") + matches_request = value == requested + observation = _floorplan_geometry_observation(feature_path, report_path) + complete = _floorplan_observation_complete(observation) + status = ( + "used" + if complete and mode == "die_util" and value is not None and matches_request + else "unknown" + ) + if complete and mode != "die_util" and value is not None and matches_request: + status = "not_activated" + evidence = { + "consumer_id": consumer_id, + "outcome": "geometry_constructed" if status == "used" else "evaluated", + "evidence_ref": _RUNTIME_REPORT_REF, + } + evidence["evidence_sha256"] = ( + "sha256:" + + hashlib.sha256( + json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + report = { + "knob_id": knob_id, + "requested_value": requested, + "tool": { + "name": "ECC-Floorplan", + "revision": FLOORPLAN_RUNTIME_REPORT_REVISION, + "source_sha256": _source_sha256(), + }, + "application_status": "applied" if complete and matches_request else "unknown", + "effective_initial": {"value": value, "unit": "ratio"}, + "effective_final": {"value": value, "unit": "ratio"}, + "activation": { + "status": status, + "consumers": [evidence] if status in {"used", "not_activated"} else [], + }, + "transitions": [], + } + if observation is not None: + report["consumer_observation"] = observation + if feature_path is not None and not complete: + report["application_status"] = "unknown" + report["activation"] = {"status": "unknown", "consumers": []} + _write_runtime_report(workspace_dir, report) + + +def _floorplan_geometry_observation( + feature_path: str | Path | None, report_path: str | Path | None +) -> dict | None: + if not feature_path or not Path(feature_path).is_file(): + return None + try: + feature = json.loads(Path(feature_path).read_text(encoding="utf-8")) + layout = feature["Design Layout"] + width = layout["core_bounding_width"] + height = layout["core_bounding_height"] + area = layout.get("core_area") + except (OSError, ValueError, KeyError, TypeError): + return None + numeric = all(isinstance(item, (int, float)) and isfinite(item) for item in (width, height)) + if not numeric or width <= 0 or height <= 0: + return None + ratio = width / height + rows, sites = _floorplan_report_counts(report_path) + return { + "core_geometry": { + "width": {"value": width, "unit": "um"}, + "height": {"value": height, "unit": "um"}, + "area": {"value": area, "unit": "um^2"}, + "aspect_ratio": {"value": ratio, "unit": "ratio"}, + }, + "rows": {"count": rows, "observed": rows is not None}, + "sites": {"count": sites, "observed": sites is not None}, + } + + +def _floorplan_observation_complete(observation: dict | None) -> bool: + if not observation: + return False + geometry = observation.get("core_geometry", {}) + return all( + geometry.get(name, {}).get("value") is not None + for name in ("width", "height", "area", "aspect_ratio") + ) and ( + observation.get("rows", {}).get("observed") is True + and observation.get("sites", {}).get("observed") is True + ) + + +def _floorplan_report_counts(report_path: str | Path | None) -> tuple[int | None, int | None]: + if not report_path or not Path(report_path).is_file(): + return None, None + try: + text = Path(report_path).read_text(encoding="utf-8", errors="replace") + except OSError: + return None, None + values = {} + for name in ("Site", "Row"): + match = re.search(rf"Number\s*-\s*{name}[^0-9]*(\d+)", text) + if match: + values[name] = int(match.group(1)) + return values.get("Row"), values.get("Site") diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 0f473aa32..66f0b3878 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -1,10 +1,6 @@ #!/usr/bin/env python -import hashlib -import json import os -import re import shutil -from math import isfinite from pathlib import Path from chipcompiler.data import ( @@ -22,6 +18,10 @@ save_rcx_spef_feature_facts, ) from chipcompiler.tools.ecc.module import ECCToolsModule +from chipcompiler.tools.ecc.parameter_runtime_report import ( + _write_cts_parameter_runtime_report, + _write_floorplan_parameter_runtime_report, +) from chipcompiler.tools.ecc.plot import ECCToolsPlot from chipcompiler.tools.ecc.sta_artifacts import discard_sta_outputs from chipcompiler.tools.ecc.sta_qor import ( @@ -49,10 +49,6 @@ ) -def _runner_source_sha256() -> str: - return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() - - class EccDesignReadError(RuntimeError): """Raised when ECC cannot construct a database from a design input.""" @@ -599,59 +595,6 @@ def run_cts(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No return reslut -def _write_cts_parameter_runtime_report( - workspace: Workspace, - config_path: str | Path, - *, - engine_succeeded: bool, -) -> None: - """Record CTS config effectiveness without claiming unobserved activation.""" - workspace_dir = getattr(workspace, "directory", None) - if workspace_dir is None: - return - materialization_path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" - if not materialization_path.is_file(): - return - try: - materialization = json.loads(materialization_path.read_text(encoding="utf-8")) - patch = next( - item for item in materialization["patch"] if item.get("knob_id") == "cts.max_fanout" - ) - value = json.loads(Path(config_path).read_text(encoding="utf-8"))["max_fanout"] - except (OSError, ValueError, KeyError, TypeError, StopIteration): - return - - requested = patch.get("value") - matches_request = type(value) is int and value == requested - effective = value if engine_succeeded else None - report = { - "knob_id": "cts.max_fanout", - "requested_value": requested, - "tool": { - "name": "ECC-CTS", - "revision": "ecc.cts.parameter_runtime_report.v1", - "source_sha256": _runner_source_sha256(), - }, - "application_status": ("applied" if engine_succeeded and matches_request else "unknown"), - "effective_initial": {"value": effective, "unit": "fanout"}, - "effective_final": {"value": effective, "unit": "fanout"}, - "activation": {"status": "unknown", "consumers": []}, - "consumer_observation": { - "config_value": value, - "engine_succeeded": engine_succeeded, - "activation_evidence_complete": False, - }, - "transitions": [], - } - output_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" - output_path.parent.mkdir(parents=True, exist_ok=True) - temporary = output_path.with_name(output_path.name + ".tmp") - temporary.write_text( - json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" - ) - os.replace(temporary, output_path) - - def run_routing( workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | None = None ) -> bool: @@ -850,151 +793,6 @@ def run_floorplan( return reslut -def _write_floorplan_parameter_runtime_report( - workspace: Workspace, - config_path: str | Path, - *, - feature_path: str | Path | None = None, - report_path: str | Path | None = None, -) -> None: - """Record the candidate knob consumed by iFP's native die builder.""" - workspace_dir = getattr(workspace, "directory", None) - if workspace_dir is None: - return - materialization_path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" - if not materialization_path.is_file(): - return - try: - materialization = json.loads(materialization_path.read_text(encoding="utf-8")) - patch = next( - item - for item in materialization["patch"] - if item.get("knob_id") in {"floorplan.core_util", "floorplan.aspect_ratio"} - ) - floorplan = json.loads(Path(config_path).read_text(encoding="utf-8")) - die_builder = floorplan["die_builder"] - die_util = die_builder["die_util"] - except (OSError, ValueError, KeyError, TypeError, StopIteration): - return - - knob_id = patch["knob_id"] - field, consumer_id = { - "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), - "floorplan.aspect_ratio": ("aspect_ratio", "ifp.die_builder.die_aspect_ratio"), - }[knob_id] - value = die_util.get(field) - requested = patch.get("value") - mode = die_builder.get("mode") - matches_request = value == requested - observation = _floorplan_geometry_observation(feature_path, report_path) - complete = _floorplan_observation_complete(observation) - status = ( - "used" - if complete and mode == "die_util" and value is not None and matches_request - else "unknown" - ) - if complete and mode != "die_util" and value is not None and matches_request: - status = "not_activated" - evidence = { - "consumer_id": consumer_id, - "outcome": "geometry_constructed" if status == "used" else "evaluated", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - } - evidence["evidence_sha256"] = ( - "sha256:" - + hashlib.sha256( - json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - ) - report = { - "knob_id": knob_id, - "requested_value": requested, - "tool": { - "name": "ECC-Floorplan", - "revision": "ecc.floorplan.parameter_runtime_report.v2", - "source_sha256": _runner_source_sha256(), - }, - "application_status": "applied" if complete and matches_request else "unknown", - "effective_initial": {"value": value, "unit": "ratio"}, - "effective_final": {"value": value, "unit": "ratio"}, - "activation": { - "status": status, - "consumers": [evidence] if status in {"used", "not_activated"} else [], - }, - "transitions": [], - } - if observation is not None: - report["consumer_observation"] = observation - if feature_path is not None and not _floorplan_observation_complete(observation): - report["application_status"] = "unknown" - report["activation"] = {"status": "unknown", "consumers": []} - output_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" - output_path.parent.mkdir(parents=True, exist_ok=True) - temporary = output_path.with_name(output_path.name + ".tmp") - temporary.write_text( - json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" - ) - os.replace(temporary, output_path) - - -def _floorplan_geometry_observation( - feature_path: str | Path | None, report_path: str | Path | None -) -> dict | None: - if not feature_path or not Path(feature_path).is_file(): - return None - try: - feature = json.loads(Path(feature_path).read_text(encoding="utf-8")) - layout = feature["Design Layout"] - width = layout["core_bounding_width"] - height = layout["core_bounding_height"] - area = layout.get("core_area") - except (OSError, ValueError, KeyError, TypeError): - return None - numeric = all(isinstance(item, (int, float)) and isfinite(item) for item in (width, height)) - if not numeric or width <= 0 or height <= 0: - return None - ratio = width / height - rows, sites = _floorplan_report_counts(report_path) - return { - "core_geometry": { - "width": {"value": width, "unit": "um"}, - "height": {"value": height, "unit": "um"}, - "area": {"value": area, "unit": "um^2"}, - "aspect_ratio": {"value": ratio, "unit": "ratio"}, - }, - "rows": {"count": rows, "observed": rows is not None}, - "sites": {"count": sites, "observed": sites is not None}, - } - - -def _floorplan_observation_complete(observation: dict | None) -> bool: - if not observation: - return False - geometry = observation.get("core_geometry", {}) - return all( - geometry.get(name, {}).get("value") is not None - for name in ("width", "height", "area", "aspect_ratio") - ) and ( - observation.get("rows", {}).get("observed") is True - and observation.get("sites", {}).get("observed") is True - ) - - -def _floorplan_report_counts(report_path: str | Path | None) -> tuple[int | None, int | None]: - if not report_path or not Path(report_path).is_file(): - return None, None - try: - text = Path(report_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return None, None - values = {} - for name in ("Site", "Row"): - match = re.search(rf"Number\s*-\s*{name}[^0-9]*(\d+)", text) - if match: - values[name] = int(match.group(1)) - return values.get("Row"), values.get("Site") - - def run_harden( workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | None = None ) -> bool: diff --git a/test/tools/ecc/test_cts_runtime_report.py b/test/tools/ecc/test_cts_runtime_report.py index b654f0186..223bf5ab0 100644 --- a/test/tools/ecc/test_cts_runtime_report.py +++ b/test/tools/ecc/test_cts_runtime_report.py @@ -5,7 +5,7 @@ from pathlib import Path from types import SimpleNamespace -from chipcompiler.tools.ecc.runner import _write_cts_parameter_runtime_report +from chipcompiler.tools.ecc.parameter_runtime_report import _write_cts_parameter_runtime_report def _write_candidate(tmp_path: Path, value: int) -> None: diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py index 2c5b1a9f8..b914997db 100644 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ b/test/tools/ecc/test_floorplan_runtime_report.py @@ -5,7 +5,9 @@ from pathlib import Path from types import SimpleNamespace -from chipcompiler.tools.ecc.runner import _write_floorplan_parameter_runtime_report +from chipcompiler.tools.ecc.parameter_runtime_report import ( + _write_floorplan_parameter_runtime_report, +) def _write_candidate(tmp_path: Path, knob_id: str, value: float) -> None: From 7d5fc859e30845652cd8e72a900024c197033cdf Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 4 Sep 2026 11:28:37 +0800 Subject: [PATCH 61/90] refactor: isolate DreamPlace runtime reporting --- chipcompiler/tools/ecc_dreamplace/module.py | 312 +---------------- .../parameter_runtime_report.py | 323 ++++++++++++++++++ .../test_parameter_runtime_report.py | 48 ++- 3 files changed, 376 insertions(+), 307 deletions(-) create mode 100644 chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index 0d11c1cde..e9163cd09 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -1,15 +1,18 @@ #!/usr/bin/env python -import hashlib import json import logging import os import sys -from contextlib import contextmanager, suppress +from contextlib import contextmanager from pathlib import Path from chipcompiler.data import StepEnum, Workspace, WorkspaceStep from chipcompiler.tools.ecc.module import ECCToolsModule +from chipcompiler.tools.ecc_dreamplace.parameter_runtime_report import ( + _capture_native_runtime, + _write_parameter_runtime_report, +) from chipcompiler.utility.path import optional_path, path_text _LEGALIZE_OWNERS = frozenset( @@ -18,7 +21,6 @@ StepEnum.TIMING_OPT.value, } ) -DREAMPLACE_RUNTIME_REPORT_REVISION = "ecc.dreamplace.parameter_runtime_report.v2" class DreamplaceModule: @@ -118,7 +120,7 @@ def _run(self, *, legalize_only: bool) -> bool: engine = PlacementEngine(params) engine.setup_rawdb(ecc_module=self.ecc_module) - with _capture_native_runtime() as native_runtime_probe: + with _capture_native_runtime(self.workspace) as native_runtime_probe: ppa = engine.run() engine.native_runtime_probe = native_runtime_probe @@ -151,305 +153,3 @@ def run_legalization(self) -> bool: __all__ = ["DreamplaceModule"] - - -def _runtime_unit(knob_id: str) -> str: - if knob_id.endswith("routability_opt"): - return "boolean" - if knob_id.endswith("cell_padding_x"): - return "dbu" - if knob_id.endswith("density_weight"): - return "objective_weight" - return "ratio" - - -def _write_parameter_runtime_report( - workspace: Workspace, - params, - *, - engine=None, - ppa: dict | None = None, - engine_succeeded: bool = False, -) -> None: - """Record the selected candidate knob at the native DreamPlace boundary.""" - patch = _candidate_patch(workspace) - if patch is None: - return - knob_id = patch.get("knob_id") - consumer_by_knob = { - "place.target_density": "dreamplace.density_objective", - "place.target_overflow": "dreamplace.overflow_predicate", - "place.cell_padding_x": "dreamplace.cell_size_expansion", - "place.routability_opt": "dreamplace.routability_branch", - "place.density_weight": "dreamplace.density_preconditioner", - } - if knob_id not in consumer_by_knob: - return - consumer_id = consumer_by_knob[knob_id] - observation = _consumer_observation(workspace, knob_id, patch.get("value"), params, engine, ppa) - value = _effective_value(knob_id, params, observation) - status = _activation_status(knob_id, value, observation, engine_succeeded=engine_succeeded) - outcome = "evaluated" if knob_id == "place.target_overflow" or status != "used" else "entered" - evidence_payload = { - "consumer_id": consumer_id, - "outcome": outcome, - "consumer_observation": observation, - } - evidence = { - "consumer_id": consumer_id, - "outcome": outcome, - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": _payload_sha256(evidence_payload), - } - report = { - "knob_id": knob_id, - "requested_value": patch.get("value"), - "tool": { - "name": "DREAMPlace", - "revision": DREAMPLACE_RUNTIME_REPORT_REVISION, - "source_sha256": _source_sha256(), - }, - "application_status": "applied" if value is not None else "unknown", - "effective_initial": {"value": value, "unit": _runtime_unit(knob_id)}, - "effective_final": {"value": value, "unit": _runtime_unit(knob_id)}, - "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, - "transitions": ( - _runtime_transitions(knob_id, patch.get("value"), value, evidence) - if status == "used" - else [] - ), - "consumer_observation": observation, - } - report_path = Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" - _write_json_atomic(report_path, report) - - -def _candidate_patch(workspace: Workspace) -> dict | None: - path = Path(workspace.directory) / "analysis" / "candidate_materialization.v1.json" - if not path.is_file(): - return None - try: - return json.loads(path.read_text(encoding="utf-8"))["patch"][0] - except (OSError, ValueError, KeyError, IndexError, TypeError): - return None - - -def _write_json_atomic(path: Path, payload: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text( - json.dumps(payload, sort_keys=True, separators=(",", ":")), encoding="utf-8" - ) - os.replace(temporary, path) - - -def _consumer_observation(workspace, knob_id, requested, params, engine, ppa) -> dict: - ppa = ppa if isinstance(ppa, dict) else {} - iterations = ppa.get("iteration") - valid_iterations = type(iterations) is int and iterations > 0 - if knob_id == "place.target_density": - data = getattr( - getattr(getattr(engine, "placer", None), "data_collections", None), - "target_density", - None, - ) - tensor_value = _scalar_value(data) - effective = _scalar_value(getattr(params, "target_density", None)) - return { - "requested_target_density": requested, - "effective_target_density": effective, - "density_tensor_value": tensor_value, - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations and tensor_value == effective, - } - if knob_id == "place.target_overflow": - overflows = _native_overflow_values(engine) - threshold = _scalar_value(getattr(params, "stop_overflow", None)) - minimum = min(overflows) if overflows else None - return { - "effective_stop_overflow": threshold, - "final_overflow": _scalar_value(ppa.get("overflow")), - "placement_iteration_count": iterations, - "comparison_count": len(overflows), - "minimum_observed_overflow": minimum, - "threshold_reached": minimum <= threshold - if minimum is not None and threshold is not None - else None, - "evidence_complete": valid_iterations and bool(overflows) and threshold is not None, - } - if knob_id == "place.cell_padding_x": - placedb = getattr(engine, "placedb", None) - effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) - movable = getattr(placedb, "num_movable_nodes", None) - return { - "requested_padding_dbu": requested, - "effective_padding_dbu": effective, - "movable_node_count": movable, - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations - and effective is not None - and type(movable) is int, - } - if knob_id == "place.density_weight": - probe = _native_runtime_probe(engine) - initializations = probe.get("density_weight_initializations", []) - updates = probe.get("density_weight_updates", []) - initial = initializations[0] if initializations else None - final = ( - updates[-1]["after"] if updates else initializations[-1] if initializations else None - ) - return { - "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), - "internal_initial_density_weight": initial, - "density_weight_updates": updates, - "density_weight_update_count": len(updates), - "final_internal_density_weight": final, - "final_objective": _scalar_value(ppa.get("objective")), - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations - and initial is not None - and _scalar_value(ppa.get("objective")) is not None, - } - rounds = _native_runtime_probe(engine).get("routability_branch_round_count") - return {"branch_round_count": rounds, "evidence_complete": isinstance(rounds, int)} - - -def _effective_value(knob_id: str, params, observation: dict): - if knob_id == "place.target_density": - return observation["effective_target_density"] - if knob_id == "place.cell_padding_x": - return observation["effective_padding_dbu"] - key = { - "place.target_overflow": "stop_overflow", - "place.routability_opt": "routability_opt_flag", - "place.density_weight": "density_weight", - }[knob_id] - return _scalar_value(getattr(params, key, None)) - - -def _activation_status(knob_id: str, value, observation: dict, *, engine_succeeded: bool) -> str: - if not engine_succeeded or not observation.get("evidence_complete"): - return "unknown" - if knob_id == "place.routability_opt" and value in (False, 0): - return "not_activated" - if knob_id == "place.routability_opt" and not observation.get("branch_round_count"): - return "not_activated" - if knob_id == "place.cell_padding_x" and value == 0: - return "not_activated" - return "used" - - -def _runtime_transitions(knob_id: str, requested, effective, evidence: dict) -> list[dict]: - if knob_id != "place.target_density" or not isinstance(requested, (int, float)): - return [] - if not isinstance(effective, (int, float)) or effective <= requested: - return [] - return [ - { - "sequence": 0, - "from": "materialized", - "to": "overridden", - "value": effective, - "reason": "DREAMPlace utilization lower bound", - "rule_id": "dreamplace.target_density.utilization_floor", - "evidence_ref": evidence["evidence_ref"], - "evidence_sha256": evidence["evidence_sha256"], - } - ] - - -def _scalar_value(value): - with suppress(AttributeError): - value = value.item() - return value if type(value) in {bool, int, float} else None - - -def _payload_sha256(payload: dict) -> str: - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() - return "sha256:" + hashlib.sha256(encoded).hexdigest() - - -def _source_sha256() -> str: - return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() - - -def _native_runtime_probe(engine) -> dict: - probe = getattr(engine, "native_runtime_probe", None) - return probe if isinstance(probe, dict) else {} - - -def _native_overflow_values(engine) -> list[float]: - metrics = getattr(engine, "metrics", None) - values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] - return [value for item in values if (value := _scalar_value(item)) is not None] - - -def _native_numeric(value): - for operation in ("detach", "cpu", "tolist"): - with suppress(AttributeError): - value = getattr(value, operation)() - if type(value) in {int, float}: - return value - if isinstance(value, list) and value and all(type(item) in {int, float} for item in value): - return value - return None - - -@contextmanager -def _capture_native_runtime(): - from dreamplace.PlaceObj import PlaceObj - - probe = { - "density_weight_initializations": [], - "density_weight_updates": [], - "routability_branch_round_count": 0, - } - original_init = PlaceObj.__init__ - - def observed_init(model, *args, **kwargs): - original_init(model, *args, **kwargs) - _observe_native_model(model, probe) - - PlaceObj.__init__ = observed_init - try: - yield probe - finally: - PlaceObj.__init__ = original_init - - -def _observe_native_model(model, probe: dict) -> None: - initialize = model.initialize_density_weight - - def observed_initialize(*args, **kwargs): - result = initialize(*args, **kwargs) - if (value := _native_numeric(result)) is not None: - probe["density_weight_initializations"].append(value) - return result - - model.initialize_density_weight = observed_initialize - operations = model.op_collections - update = getattr(operations, "update_density_weight_op", None) - if callable(update): - - def observed_update(*args, **kwargs): - before = _native_numeric(model.density_weight) - result = update(*args, **kwargs) - after = _native_numeric(model.density_weight) - probe["density_weight_updates"].append( - { - "sequence": len(probe["density_weight_updates"]), - "before": before, - "after": after, - } - ) - return result - - operations.update_density_weight_op = observed_update - adjust_area = getattr(operations, "adjust_node_area_op", None) - if callable(adjust_area): - - def observed_adjust_area(*args, **kwargs): - probe["routability_branch_round_count"] += 1 - return adjust_area(*args, **kwargs) - - operations.adjust_node_area_op = observed_adjust_area diff --git a/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py b/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py new file mode 100644 index 000000000..c770f8392 --- /dev/null +++ b/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python + +import hashlib +import json +from contextlib import contextmanager, suppress +from pathlib import Path + +from chipcompiler.data import Workspace +from chipcompiler.utility.json import json_write + +DREAMPLACE_RUNTIME_REPORT_REVISION = "ecc.dreamplace.parameter_runtime_report.v2" +_NATIVE_PROBE_KNOBS = frozenset( + { + "place.density_weight", + "place.routability_opt", + } +) + + +def _runtime_unit(knob_id: str) -> str: + if knob_id.endswith("routability_opt"): + return "boolean" + if knob_id.endswith("cell_padding_x"): + return "dbu" + if knob_id.endswith("density_weight"): + return "objective_weight" + return "ratio" + + +def _write_parameter_runtime_report( + workspace: Workspace, + params, + *, + engine=None, + ppa: dict | None = None, + engine_succeeded: bool = False, +) -> None: + """Record the selected candidate knob at the native DreamPlace boundary.""" + workspace_dir = workspace.directory + if workspace_dir is None: + return + patch = _candidate_patch(workspace) + if patch is None: + return + knob_id = patch.get("knob_id") + consumer_by_knob = { + "place.target_density": "dreamplace.density_objective", + "place.target_overflow": "dreamplace.overflow_predicate", + "place.cell_padding_x": "dreamplace.cell_size_expansion", + "place.routability_opt": "dreamplace.routability_branch", + "place.density_weight": "dreamplace.density_preconditioner", + } + if knob_id not in consumer_by_knob: + return + consumer_id = consumer_by_knob[knob_id] + observation = _consumer_observation(knob_id, patch.get("value"), params, engine, ppa) + value = _effective_value(knob_id, params, observation) + status = _activation_status(knob_id, value, observation, engine_succeeded=engine_succeeded) + outcome = "evaluated" if knob_id == "place.target_overflow" or status != "used" else "entered" + evidence_payload = { + "consumer_id": consumer_id, + "outcome": outcome, + "consumer_observation": observation, + } + evidence = { + "consumer_id": consumer_id, + "outcome": outcome, + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": _payload_sha256(evidence_payload), + } + report = { + "knob_id": knob_id, + "requested_value": patch.get("value"), + "tool": { + "name": "DREAMPlace", + "revision": DREAMPLACE_RUNTIME_REPORT_REVISION, + "source_sha256": _source_sha256(), + }, + "application_status": "applied" if value is not None else "unknown", + "effective_initial": {"value": value, "unit": _runtime_unit(knob_id)}, + "effective_final": {"value": value, "unit": _runtime_unit(knob_id)}, + "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, + "transitions": ( + _runtime_transitions(knob_id, patch.get("value"), value, evidence) + if status == "used" + else [] + ), + "consumer_observation": observation, + } + report_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" + report_path.parent.mkdir(parents=True, exist_ok=True) + if not json_write(report_path, report, indent=None): + raise OSError(f"Failed to write DreamPlace parameter runtime report: {report_path}") + + +def _candidate_patch(workspace: Workspace) -> dict | None: + workspace_dir = workspace.directory + if workspace_dir is None: + return None + path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8"))["patch"][0] + except (OSError, ValueError, KeyError, IndexError, TypeError): + return None + + +def _consumer_observation(knob_id, requested, params, engine, ppa) -> dict: + ppa = ppa if isinstance(ppa, dict) else {} + iterations = ppa.get("iteration") + valid_iterations = type(iterations) is int and iterations > 0 + if knob_id == "place.target_density": + data = getattr( + getattr(getattr(engine, "placer", None), "data_collections", None), + "target_density", + None, + ) + tensor_value = _scalar_value(data) + effective = _scalar_value(getattr(params, "target_density", None)) + return { + "requested_target_density": requested, + "effective_target_density": effective, + "density_tensor_value": tensor_value, + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations and tensor_value == effective, + } + if knob_id == "place.target_overflow": + overflows = _native_overflow_values(engine) + threshold = _scalar_value(getattr(params, "stop_overflow", None)) + minimum = min(overflows) if overflows else None + return { + "effective_stop_overflow": threshold, + "final_overflow": _scalar_value(ppa.get("overflow")), + "placement_iteration_count": iterations, + "comparison_count": len(overflows), + "minimum_observed_overflow": minimum, + "threshold_reached": minimum <= threshold + if minimum is not None and threshold is not None + else None, + "evidence_complete": valid_iterations and bool(overflows) and threshold is not None, + } + if knob_id == "place.cell_padding_x": + placedb = getattr(engine, "placedb", None) + effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) + movable = getattr(placedb, "num_movable_nodes", None) + return { + "requested_padding_dbu": requested, + "effective_padding_dbu": effective, + "movable_node_count": movable, + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations + and effective is not None + and type(movable) is int, + } + if knob_id == "place.density_weight": + probe = _native_runtime_probe(engine) + initializations = probe.get("density_weight_initializations", []) + updates = probe.get("density_weight_updates", []) + initial = initializations[0] if initializations else None + final = ( + updates[-1]["after"] if updates else initializations[-1] if initializations else None + ) + return { + "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), + "internal_initial_density_weight": initial, + "density_weight_updates": updates, + "density_weight_update_count": len(updates), + "final_internal_density_weight": final, + "final_objective": _scalar_value(ppa.get("objective")), + "placement_iteration_count": iterations, + "evidence_complete": valid_iterations + and initial is not None + and _scalar_value(ppa.get("objective")) is not None, + } + rounds = _native_runtime_probe(engine).get("routability_branch_round_count") + return {"branch_round_count": rounds, "evidence_complete": isinstance(rounds, int)} + + +def _effective_value(knob_id: str, params, observation: dict): + if knob_id == "place.target_density": + return observation["effective_target_density"] + if knob_id == "place.cell_padding_x": + return observation["effective_padding_dbu"] + key = { + "place.target_overflow": "stop_overflow", + "place.routability_opt": "routability_opt_flag", + "place.density_weight": "density_weight", + }[knob_id] + return _scalar_value(getattr(params, key, None)) + + +def _activation_status(knob_id: str, value, observation: dict, *, engine_succeeded: bool) -> str: + if not engine_succeeded or not observation.get("evidence_complete"): + return "unknown" + if knob_id == "place.routability_opt" and value in (False, 0): + return "not_activated" + if knob_id == "place.routability_opt" and not observation.get("branch_round_count"): + return "not_activated" + if knob_id == "place.cell_padding_x" and value == 0: + return "not_activated" + return "used" + + +def _runtime_transitions(knob_id: str, requested, effective, evidence: dict) -> list[dict]: + if knob_id != "place.target_density" or not isinstance(requested, (int, float)): + return [] + if not isinstance(effective, (int, float)) or effective <= requested: + return [] + return [ + { + "sequence": 0, + "from": "materialized", + "to": "overridden", + "value": effective, + "reason": "DREAMPlace utilization lower bound", + "rule_id": "dreamplace.target_density.utilization_floor", + "evidence_ref": evidence["evidence_ref"], + "evidence_sha256": evidence["evidence_sha256"], + } + ] + + +def _scalar_value(value): + with suppress(AttributeError): + value = value.item() + return value if type(value) in {bool, int, float} else None + + +def _payload_sha256(payload: dict) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _source_sha256() -> str: + return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + + +def _native_runtime_probe(engine) -> dict: + probe = getattr(engine, "native_runtime_probe", None) + return probe if isinstance(probe, dict) else {} + + +def _native_overflow_values(engine) -> list[float]: + metrics = getattr(engine, "metrics", None) + values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] + return [value for item in values if (value := _scalar_value(item)) is not None] + + +def _native_numeric(value): + for operation in ("detach", "cpu", "tolist"): + with suppress(AttributeError): + value = getattr(value, operation)() + if type(value) in {int, float}: + return value + if isinstance(value, list) and value and all(type(item) in {int, float} for item in value): + return value + return None + + +@contextmanager +def _capture_native_runtime(workspace: Workspace): + patch = _candidate_patch(workspace) + if patch is None or patch.get("knob_id") not in _NATIVE_PROBE_KNOBS: + yield {} + return + + from dreamplace.PlaceObj import PlaceObj + + probe = { + "density_weight_initializations": [], + "density_weight_updates": [], + "routability_branch_round_count": 0, + } + original_init = PlaceObj.__init__ + + def observed_init(model, *args, **kwargs): + original_init(model, *args, **kwargs) + _observe_native_model(model, probe) + + PlaceObj.__init__ = observed_init + try: + yield probe + finally: + PlaceObj.__init__ = original_init + + +def _observe_native_model(model, probe: dict) -> None: + initialize = model.initialize_density_weight + + def observed_initialize(*args, **kwargs): + result = initialize(*args, **kwargs) + if (value := _native_numeric(result)) is not None: + probe["density_weight_initializations"].append(value) + return result + + model.initialize_density_weight = observed_initialize + operations = model.op_collections + update = getattr(operations, "update_density_weight_op", None) + if callable(update): + + def observed_update(*args, **kwargs): + before = _native_numeric(model.density_weight) + result = update(*args, **kwargs) + after = _native_numeric(model.density_weight) + probe["density_weight_updates"].append( + { + "sequence": len(probe["density_weight_updates"]), + "before": before, + "after": after, + } + ) + return result + + operations.update_density_weight_op = observed_update + adjust_area = getattr(operations, "adjust_node_area_op", None) + if callable(adjust_area): + + def observed_adjust_area(*args, **kwargs): + probe["routability_branch_round_count"] += 1 + return adjust_area(*args, **kwargs) + + operations.adjust_node_area_op = observed_adjust_area diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py index 547216e9e..5654d91fc 100644 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py @@ -1,9 +1,11 @@ from __future__ import annotations import json +import sys from types import SimpleNamespace -from chipcompiler.tools.ecc_dreamplace.module import ( +from chipcompiler.tools.ecc_dreamplace.parameter_runtime_report import ( + _capture_native_runtime, _observe_native_model, _write_parameter_runtime_report, ) @@ -330,6 +332,50 @@ def update_density_weight(): } +def test_native_probe_skips_candidate_without_runtime_hooks(tmp_path, monkeypatch): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), + encoding="utf-8", + ) + + class PlaceObj: + pass + + original_init = PlaceObj.__init__ + monkeypatch.setitem(sys.modules, "dreamplace.PlaceObj", SimpleNamespace(PlaceObj=PlaceObj)) + + with _capture_native_runtime(SimpleNamespace(directory=tmp_path)) as probe: + assert probe == {} + assert PlaceObj.__init__ is original_init + + +def test_native_probe_restores_runtime_hooks(tmp_path, monkeypatch): + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text( + json.dumps({"patch": [{"knob_id": "place.density_weight", "value": 0.001}]}), + encoding="utf-8", + ) + + class PlaceObj: + pass + + original_init = PlaceObj.__init__ + monkeypatch.setitem(sys.modules, "dreamplace.PlaceObj", SimpleNamespace(PlaceObj=PlaceObj)) + + with _capture_native_runtime(SimpleNamespace(directory=tmp_path)) as probe: + assert PlaceObj.__init__ is not original_init + assert probe == { + "density_weight_initializations": [], + "density_weight_updates": [], + "routability_branch_round_count": 0, + } + + assert PlaceObj.__init__ is original_init + + def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() From 4f293cb6381fca3af095ff63cfb5209fdf2042fe Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Fri, 4 Sep 2026 22:17:29 +0800 Subject: [PATCH 62/90] feat: observe parameter runtime from agent --- agent/data/floorplan_parameter_observer.py | 264 +++++++ agent/data/parameter_runtime_observer.py | 693 ++++++++++++++++++ .../test/test_parameter_receipt_artifacts.py | 4 +- agent/test/test_parameter_runtime_observer.py | 374 ++++++++++ agent/test/test_tools.py | 57 ++ agent/test/test_workspace_api.py | 2 +- agent/tools.py | 10 +- .../tools/ecc/parameter_runtime_report.py | 217 ------ chipcompiler/tools/ecc/runner.py | 13 - chipcompiler/tools/ecc_dreamplace/module.py | 20 +- .../parameter_runtime_report.py | 323 -------- test/tools/ecc/test_cts_runtime_report.py | 61 -- .../ecc/test_floorplan_runtime_report.py | 171 ----- .../test_parameter_runtime_report.py | 394 ---------- 14 files changed, 1400 insertions(+), 1203 deletions(-) create mode 100644 agent/data/floorplan_parameter_observer.py create mode 100644 agent/data/parameter_runtime_observer.py create mode 100644 agent/test/test_parameter_runtime_observer.py delete mode 100644 chipcompiler/tools/ecc/parameter_runtime_report.py delete mode 100644 chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py delete mode 100644 test/tools/ecc/test_cts_runtime_report.py delete mode 100644 test/tools/ecc/test_floorplan_runtime_report.py delete mode 100644 test/tools/ecc_dreamplace/test_parameter_runtime_report.py diff --git a/agent/data/floorplan_parameter_observer.py b/agent/data/floorplan_parameter_observer.py new file mode 100644 index 000000000..77c42c0cb --- /dev/null +++ b/agent/data/floorplan_parameter_observer.py @@ -0,0 +1,264 @@ +"""Agent-owned floorplan boundary and realized-geometry observation.""" + +import json +import math +from collections.abc import Iterator +from contextlib import ExitStack, contextmanager +from functools import partial, wraps +from pathlib import Path +from threading import RLock, get_ident +from typing import Any + +from .candidate_artifacts import canonical_json_bytes, sha256_bytes, sha256_path + +FLOORPLAN_OBSERVER_REVISION = "ecc.agent.floorplan_parameter_observer.v1" +FLOORPLAN_KNOBS = frozenset({"floorplan.core_util", "floorplan.aspect_ratio"}) +RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" +_MISSING = object() + +# ponytail: serialize same-process observers; use permanent thread-local hooks +# if parallel flow throughput matters. +_OBSERVATION_LOCK = RLock() + + +@contextmanager +def capture_floorplan(patch: dict[str, Any]) -> Iterator[dict[str, Any]]: + from chipcompiler.tools.ecc.module import ECCToolsModule + + boundary = { + "init_fp_call_count": 0, + "run_fp_call_count": 0, + } + with _OBSERVATION_LOCK, ExitStack() as stack: + _patch_method( + stack, + ECCToolsModule, + "init_fp", + partial(_observe_floorplan_init, boundary), + ) + _patch_method( + stack, + ECCToolsModule, + "run_fp", + partial(_observe_floorplan_run, boundary), + ) + yield boundary + + +def _patch_method(stack, owner, name, observer) -> None: + original = getattr(owner, name) + owner_thread = get_ident() + + @wraps(original) + def observed(*args, **kwargs): + if get_ident() != owner_thread: + return original(*args, **kwargs) + return observer(original, *args, **kwargs) + + previous = vars(owner).get(name, _MISSING) + setattr(owner, name, observed) + stack.callback(_restore_attribute, owner, name, previous) + + +def _restore_attribute(owner, name, previous) -> None: + if previous is _MISSING: + delattr(owner, name) + else: + setattr(owner, name, previous) + + +def _observe_floorplan_init(boundary, original, module, *args, **kwargs): + config = kwargs.get("config", args[0] if args else None) + boundary["init_fp_call_count"] += 1 + boundary["config_path"] = str(config) if config else None + return original(module, *args, **kwargs) + + +def _observe_floorplan_run(boundary, original, module, *args, **kwargs): + boundary["run_fp_call_count"] += 1 + return original(module, *args, **kwargs) + + +def build_floorplan_report( + patch: dict[str, Any], + boundary: dict[str, Any], + feature_path: str | Path | None, + *, + engine_succeeded: bool, +) -> dict[str, Any]: + knob_id = patch["knob_id"] + config_path = Path(boundary["config_path"]) if boundary.get("config_path") else None + config = _read_json(config_path) + die_builder = config.get("die_builder", {}) if config else {} + die_util = die_builder.get("die_util", {}) if isinstance(die_builder, dict) else {} + field_name, consumer_id = { + "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), + "floorplan.aspect_ratio": ( + "aspect_ratio", + "ifp.die_builder.die_aspect_ratio", + ), + }[knob_id] + configured = _scalar_value(die_util.get(field_name)) + geometry = _floorplan_geometry(feature_path) + realized = geometry.get( + "core_utilization" if knob_id == "floorplan.core_util" else "aspect_ratio" + ) + boundary_complete = ( + boundary.get("init_fp_call_count") == 1 + and boundary.get("run_fp_call_count") == 1 + and configured == patch["value"] + ) + mode_active = die_builder.get("mode") == "die_util" + used = engine_succeeded and boundary_complete and mode_active and realized is not None + not_activated = engine_succeeded and boundary_complete and not mode_active + status = "used" if used else "not_activated" if not_activated else "unknown" + observation = _floorplan_observation( + configured, + realized, + geometry, + boundary, + config_path, + die_builder.get("mode"), + mode_active=mode_active, + realized_available=used, + evidence_complete=used or not_activated, + ) + outcome = "geometry_constructed" if used else "evaluated" + evidence = _consumer_evidence(consumer_id, outcome, observation) + return { + "knob_id": knob_id, + "requested_value": patch["value"], + "tool": { + "name": "ECC-Floorplan", + "revision": FLOORPLAN_OBSERVER_REVISION, + "source_sha256": sha256_path(Path(__file__)), + }, + "application_status": ("applied" if engine_succeeded and boundary_complete else "unknown"), + "effective_initial": {"value": configured, "unit": "ratio"}, + "effective_final": {"value": realized if used else None, "unit": "ratio"}, + "activation": { + "status": status, + "consumers": [evidence] if status in {"used", "not_activated"} else [], + }, + "transitions": _floorplan_transitions(configured, realized, evidence) if used else [], + "consumer_observation": observation, + } + + +def _floorplan_observation( + configured, + realized, + geometry, + boundary, + config_path, + mode, + *, + mode_active, + realized_available, + evidence_complete, +) -> dict[str, Any]: + lifecycle = [("adopted", configured, "ratio", "agent_python_boundary")] + if mode_active: + lifecycle.append(("consumed", configured, "ratio", "native_call_boundary")) + if realized_available: + lifecycle.append(("realized", realized, "ratio", "derived_verified_artifact")) + return { + "evidence_kind": "boundary_and_derived_output", + "configured_value": configured, + "mode": mode, + "init_fp_call_count": boundary.get("init_fp_call_count", 0), + "run_fp_call_count": boundary.get("run_fp_call_count", 0), + "config_sha256": sha256_path(config_path) if config_path else None, + "core_geometry": geometry.get("core_geometry"), + "realized_core_utilization": geometry.get("core_utilization"), + "realized_aspect_ratio": geometry.get("aspect_ratio"), + "evidence_complete": evidence_complete, + "lifecycle": _lifecycle(*lifecycle), + } + + +def _consumer_evidence(consumer_id, outcome, observation) -> dict[str, Any]: + payload = { + "consumer_id": consumer_id, + "outcome": outcome, + "consumer_observation": observation, + } + return { + "consumer_id": consumer_id, + "outcome": outcome, + "evidence_ref": RUNTIME_REPORT_REF, + "evidence_sha256": sha256_bytes(canonical_json_bytes(payload)), + } + + +def _floorplan_transitions(configured, realized, evidence) -> list[dict[str, Any]]: + if configured == realized: + return [] + return [ + { + "sequence": 0, + "from": "adopted", + "to": "adjusted", + "value": realized, + "reason": "Floorplan geometry realization", + "evidence_ref": RUNTIME_REPORT_REF, + "evidence_sha256": evidence["evidence_sha256"], + } + ] + + +def _floorplan_geometry(feature_path: str | Path | None) -> dict[str, Any]: + feature = _read_json(Path(feature_path)) if feature_path else None + layout = feature.get("Design Layout", {}) if feature else {} + width = _scalar_value(layout.get("core_bounding_width")) + height = _scalar_value(layout.get("core_bounding_height")) + if width is None or height is None or width <= 0 or height <= 0: + return {} + ratio = width / height + return { + "core_utilization": _scalar_value(layout.get("core_usage")), + "aspect_ratio": ratio, + "core_geometry": { + "width": {"value": width, "unit": "um"}, + "height": {"value": height, "unit": "um"}, + "area": { + "value": _scalar_value(layout.get("core_area")), + "unit": "um^2", + }, + "aspect_ratio": {"value": ratio, "unit": "ratio"}, + }, + } + + +def _read_json(path: Path | None) -> dict[str, Any] | None: + if path is None or not path.is_file(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return value if isinstance(value, dict) else None + + +def step_path(step: Any, group: str, name: str) -> str | Path | None: + value = getattr(step, group, None) + return value.get(name) if isinstance(value, dict) else getattr(value, name, None) + + +def _scalar_value(value: Any): + if type(value) is int: + return value + return value if type(value) is float and math.isfinite(value) else None + + +def _lifecycle(*events: tuple[str, Any, str, str]) -> list[dict[str, Any]]: + return [ + { + "sequence": sequence, + "phase": phase, + "value": value, + "unit": unit, + "evidence_kind": evidence_kind, + } + for sequence, (phase, value, unit, evidence_kind) in enumerate(events) + ] diff --git a/agent/data/parameter_runtime_observer.py b/agent/data/parameter_runtime_observer.py new file mode 100644 index 000000000..81e28151f --- /dev/null +++ b/agent/data/parameter_runtime_observer.py @@ -0,0 +1,693 @@ +"""Agent-owned DREAMPlace runtime observation for controlled candidates.""" + +import math +from collections.abc import Callable, Iterator +from contextlib import ExitStack, contextmanager, suppress +from dataclasses import dataclass, field +from functools import partial, wraps +from pathlib import Path +from threading import RLock, get_ident +from typing import Any + +from .candidate_artifacts import ( + canonical_json_bytes, + sha256_bytes, + sha256_path, + write_json_atomic, +) + +DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v1" +RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" + +# ponytail: serialize same-process observers; use permanent thread-local hooks +# if parallel flow throughput matters. +_OBSERVATION_LOCK = RLock() + +DREAMPLACE_KNOBS = frozenset( + { + "place.target_density", + "place.target_overflow", + "place.cell_padding_x", + "place.routability_opt", + "place.density_weight", + } +) + + +@dataclass +class DreamplaceRecorder: + patch: dict[str, Any] + engine: Any = None + model: Any = None + ppa: dict[str, Any] = field(default_factory=dict) + placement_depth: int = 0 + probe: dict[str, Any] = field( + default_factory=lambda: { + "density_operator_call_count": 0, + "density_weight_initializations": [], + "density_weight_updates": [], + "nonlinear_place_call_count": 0, + "place_object_count": 0, + "routability_branch_round_count": 0, + "routability_operator_constructed": False, + "stop_overflow_read_count": 0, + } + ) + + +def run_with_parameter_observation( + workspace: Any, + step: Any, + materialization: dict[str, Any] | None, + invoke: Callable[[], Any], +) -> Any: + """Run a candidate step and persist runtime evidence without changing its result.""" + if materialization is None: + return invoke() + patch = materialization["patch"][0] + knob_id = patch["knob_id"] + if knob_id not in DREAMPLACE_KNOBS: + from .floorplan_parameter_observer import FLOORPLAN_KNOBS + + if knob_id not in FLOORPLAN_KNOBS: + return invoke() + + if knob_id in DREAMPLACE_KNOBS: + with _capture_dreamplace(patch) as recorder: + return _invoke_and_record( + workspace, + invoke, + lambda succeeded: _build_dreamplace_report( + patch, + recorder.engine, + recorder.ppa, + _final_probe(recorder), + engine_succeeded=succeeded, + ), + ) + + from .floorplan_parameter_observer import ( + build_floorplan_report, + capture_floorplan, + step_path, + ) + + with capture_floorplan(patch) as boundary: + return _invoke_and_record( + workspace, + invoke, + lambda succeeded: build_floorplan_report( + patch, + boundary, + step_path(step, "feature", "db"), + engine_succeeded=succeeded, + ), + ) + + +def _invoke_and_record( + workspace: Any, + invoke: Callable[[], Any], + build_report: Callable[[bool], dict[str, Any]], +) -> Any: + try: + result = invoke() + except BaseException: + _persist_report(workspace, build_report, engine_succeeded=False) + raise + _persist_report(workspace, build_report, engine_succeeded=bool(result)) + return result + + +def _persist_report( + workspace: Any, + build_report: Callable[[bool], dict[str, Any]], + *, + engine_succeeded: bool, +) -> None: + try: + report = build_report(engine_succeeded) + write_json_atomic(Path(workspace.directory) / RUNTIME_REPORT_REF, report) + except Exception: + logger = getattr(workspace, "logger", None) + if logger is not None: + logger.exception("Failed to persist parameter runtime evidence") + + +@contextmanager +def _capture_dreamplace( + patch: dict[str, Any], +) -> Iterator[DreamplaceRecorder]: + from dreamplace.macroPlaceDB import MacroPlaceDB + from dreamplace.Params import Params + from dreamplace.PlaceObj import PlaceObj + from dreamplace.Placer import PlacementEngine + + recorder = DreamplaceRecorder(patch=patch) + with _OBSERVATION_LOCK, ExitStack() as stack: + _patch_method( + stack, + PlacementEngine, + "run", + partial(_observe_placement_run, recorder), + ) + _patch_method( + stack, + PlacementEngine, + "place", + partial(_observe_placement_call, recorder), + ) + if patch["knob_id"] == "place.target_overflow": + _patch_method( + stack, + Params, + "__getattribute__", + partial(_observe_parameter_read, recorder), + ) + if patch["knob_id"] == "place.cell_padding_x": + _patch_method( + stack, + MacroPlaceDB, + "_apply_cell_padding", + partial(_observe_cell_padding, recorder), + ) + if patch["knob_id"] in { + "place.target_density", + "place.density_weight", + "place.routability_opt", + }: + _patch_method( + stack, + PlaceObj, + "__init__", + partial(_observe_place_object_init, recorder, stack), + ) + yield recorder + + +def _patch_method( + stack: ExitStack, + owner: Any, + name: str, + observer: Callable[..., Any], +) -> None: + original = getattr(owner, name) + owner_thread = get_ident() + + @wraps(original) + def observed(*args, **kwargs): + if get_ident() != owner_thread: + return original(*args, **kwargs) + return observer(original, *args, **kwargs) + + previous = vars(owner).get(name, _MISSING) + setattr(owner, name, observed) + stack.callback(_restore_attribute, owner, name, previous) + + +_MISSING = object() + + +def _restore_attribute(owner: Any, name: str, previous: Any) -> None: + if previous is _MISSING: + delattr(owner, name) + else: + setattr(owner, name, previous) + + +def _observe_placement_run(recorder, original, engine, *args, **kwargs): + try: + result = original(engine, *args, **kwargs) + finally: + recorder.engine = engine + if isinstance(result, dict): + recorder.ppa = dict(result) + return result + + +def _observe_placement_call(recorder, original, engine, *args, **kwargs): + recorder.probe["nonlinear_place_call_count"] += 1 + recorder.placement_depth += 1 + try: + return original(engine, *args, **kwargs) + finally: + recorder.placement_depth -= 1 + + +def _observe_parameter_read(recorder, original, params, name): + value = original(params, name) + if recorder.placement_depth and name == "stop_overflow": + recorder.probe["stop_overflow_read_count"] += 1 + return value + + +def _observe_cell_padding(recorder, original, placedb, params, *args, **kwargs): + normalized = _scalar_value(getattr(params, "cell_padding_x", None)) + result = original(placedb, params, *args, **kwargs) + recorder.probe["cell_padding"] = { + "normalized_padding_dbu": normalized, + "effective_padding_dbu": _scalar_value(getattr(placedb, "cell_padding_x", None)), + "geometry_apply_count": 1, + } + return result + + +def _observe_place_object_init( + recorder, + stack, + original, + model, + *args, + **kwargs, +): + result = original(model, *args, **kwargs) + recorder.model = model + recorder.probe["place_object_count"] += 1 + _observe_native_model(model, recorder, stack) + return result + + +def _observe_native_model( + model: Any, + recorder: DreamplaceRecorder, + stack: ExitStack, +) -> None: + knob_id = recorder.patch["knob_id"] + operations = model.op_collections + if knob_id == "place.target_density": + for name in ("density_op", "fence_region_density_merged_op"): + operation = getattr(operations, name, None) + if callable(operation): + _patch_method( + stack, + operations, + name, + partial(_observe_density_operator, recorder), + ) + elif knob_id == "place.density_weight": + _patch_method( + stack, + model, + "initialize_density_weight", + partial(_observe_density_weight_initialization, recorder), + ) + if callable(getattr(operations, "update_density_weight_op", None)): + _patch_method( + stack, + operations, + "update_density_weight_op", + partial(_observe_density_weight_update, recorder, model), + ) + elif knob_id == "place.routability_opt": + adjust_area = getattr(operations, "adjust_node_area_op", None) + recorder.probe["routability_operator_constructed"] = callable(adjust_area) + if callable(adjust_area): + _patch_method( + stack, + operations, + "adjust_node_area_op", + partial(_observe_routability_round, recorder), + ) + + +def _observe_density_operator(recorder, original, *args, **kwargs): + recorder.probe["density_operator_call_count"] += 1 + return original(*args, **kwargs) + + +def _observe_density_weight_initialization(recorder, original, *args, **kwargs): + result = original(*args, **kwargs) + if (value := _native_value(result)) is not None: + recorder.probe["density_weight_initializations"].append(value) + return result + + +def _observe_density_weight_update(recorder, model, original, *args, **kwargs): + before = _native_value(model.density_weight) + result = original(*args, **kwargs) + recorder.probe["density_weight_updates"].append( + { + "sequence": len(recorder.probe["density_weight_updates"]), + "before": before, + "after": _native_value(model.density_weight), + } + ) + return result + + +def _observe_routability_round(recorder, original, *args, **kwargs): + recorder.probe["routability_branch_round_count"] += 1 + return original(*args, **kwargs) + + +def _final_probe(recorder: DreamplaceRecorder) -> dict[str, Any]: + probe = dict(recorder.probe) + if recorder.model is not None: + probe["final_internal_density_weight"] = _native_value( + getattr(recorder.model, "density_weight", None) + ) + return probe + + +def _build_dreamplace_report( + patch: dict[str, Any], + engine: Any, + ppa: dict[str, Any] | None, + probe: dict[str, Any], + *, + engine_succeeded: bool, +) -> dict[str, Any]: + knob_id = patch["knob_id"] + params = getattr(engine, "params", None) + ppa = ppa if isinstance(ppa, dict) else {} + observation = _dreamplace_observation(knob_id, patch["value"], params, engine, ppa, probe) + initial, final, unit = _dreamplace_effective_values(knob_id, params, observation) + status = _dreamplace_activation_status( + knob_id, initial, observation, engine_succeeded=engine_succeeded + ) + outcome = "evaluated" if knob_id == "place.target_overflow" else "entered" + if status == "not_activated": + outcome = "evaluated" + evidence = _consumer_evidence(knob_id, outcome, observation) + return { + "knob_id": knob_id, + "requested_value": patch["value"], + "tool": { + "name": "DREAMPlace", + "revision": DREAMPLACE_OBSERVER_REVISION, + "source_sha256": sha256_path(Path(__file__)), + }, + "application_status": ( + "applied" if engine_succeeded and initial is not None else "unknown" + ), + "effective_initial": {"value": initial, "unit": unit}, + "effective_final": {"value": final, "unit": unit}, + "activation": { + "status": status, + "consumers": [evidence] if status in {"used", "not_activated"} else [], + }, + "transitions": _dreamplace_transitions( + knob_id, patch["value"], initial, evidence, observation + ) + if status == "used" + else [], + "consumer_observation": observation, + } + + +def _consumer_evidence(knob_id: str, outcome: str, observation: dict) -> dict: + consumer_id = _dreamplace_consumer(knob_id) + payload = { + "consumer_id": consumer_id, + "outcome": outcome, + "consumer_observation": observation, + } + return { + "consumer_id": consumer_id, + "outcome": outcome, + "evidence_ref": RUNTIME_REPORT_REF, + "evidence_sha256": sha256_bytes(canonical_json_bytes(payload)), + } + + +def _dreamplace_observation(knob_id, requested, params, engine, ppa, probe) -> dict: + iterations = ppa.get("iteration") + handlers = { + "place.target_density": _target_density_observation, + "place.target_overflow": _target_overflow_observation, + "place.cell_padding_x": _cell_padding_observation, + "place.density_weight": _density_weight_observation, + "place.routability_opt": _routability_observation, + } + return handlers[knob_id](requested, params, engine, ppa, probe, iterations) + + +def _target_density_observation(requested, params, engine, _ppa, probe, iterations): + tensor = _scalar_value( + getattr( + getattr(getattr(engine, "placer", None), "data_collections", None), + "target_density", + None, + ) + ) + effective = _scalar_value(getattr(params, "target_density", None)) + calls = probe.get("density_operator_call_count", 0) + return { + "requested_target_density": requested, + "effective_target_density": effective, + "density_tensor_value": tensor, + "density_operator_call_count": calls, + "placement_iteration_count": iterations, + "evidence_complete": _valid_iterations(iterations) + and _same_number(tensor, effective) + and calls > 0, + "lifecycle": _lifecycle( + ("adopted", effective, "ratio", "direct_python_runtime"), + ("consumed", tensor, "ratio", "direct_python_runtime"), + ), + } + + +def _target_overflow_observation(_requested, params, engine, ppa, probe, iterations): + overflows = _native_overflow_values(engine) + threshold = _scalar_value(getattr(params, "stop_overflow", None)) + read_count = probe.get("stop_overflow_read_count", 0) + final = _scalar_value(ppa.get("overflow")) + return { + "effective_stop_overflow": threshold, + "final_overflow": final, + "placement_iteration_count": iterations, + "predicate_owner_call_count": probe.get("nonlinear_place_call_count", 0), + "threshold_read_count": read_count, + "observed_overflow_count": len(overflows), + "minimum_observed_overflow": min(overflows) if overflows else None, + "threshold_reached": min(overflows) <= threshold + if overflows and threshold is not None + else None, + "evidence_complete": _valid_iterations(iterations) + and threshold is not None + and read_count > 0, + "lifecycle": _lifecycle( + ("adopted", threshold, "ratio", "direct_python_runtime"), + ("consumed", threshold, "ratio", "direct_python_runtime"), + ("realized", final, "overflow", "post_run_state"), + ), + } + + +def _cell_padding_observation(requested, params, engine, _ppa, probe, iterations): + placedb = getattr(engine, "placedb", None) + padding = probe.get("cell_padding", {}) + effective_dbu = _scalar_value(padding.get("effective_padding_dbu")) + restored = _scalar_value(getattr(params, "cell_padding_x", None)) + movable = getattr(placedb, "num_movable_nodes", None) + return { + "requested_padding_dbu": requested, + "normalized_padding_dbu": padding.get("normalized_padding_dbu"), + "effective_padding_dbu": effective_dbu, + "effective_padding_sites": _scalar_value(getattr(placedb, "cell_padding_x", None)), + "post_legalization_padding_sites": restored, + "representation_restored": restored == 0, + "geometry_apply_count": padding.get("geometry_apply_count", 0), + "movable_node_count": movable, + "placement_iteration_count": iterations, + "evidence_complete": _valid_iterations(iterations) + and effective_dbu is not None + and type(movable) is int + and padding.get("geometry_apply_count", 0) > 0, + "lifecycle": _lifecycle( + ( + "normalized", + padding.get("normalized_padding_dbu"), + "dbu", + "direct_python_runtime", + ), + ("consumed", effective_dbu, "dbu", "direct_python_runtime"), + ("restored", restored, "internal_site", "post_run_state"), + ), + } + + +def _density_weight_observation(_requested, params, _engine, ppa, probe, iterations): + initializations = probe.get("density_weight_initializations", []) + updates = probe.get("density_weight_updates", []) + initial = initializations[0] if initializations else None + final = probe.get("final_internal_density_weight") + if final is None: + final = updates[-1]["after"] if updates else initial + objective = _scalar_value(ppa.get("objective")) + return { + "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), + "internal_initial_density_weight": initial, + "density_weight_updates": updates, + "density_weight_update_count": len(updates), + "final_internal_density_weight": final, + "final_objective": objective, + "placement_iteration_count": iterations, + "evidence_complete": _valid_iterations(iterations) + and _runtime_scalar(initial) is not None + and _runtime_scalar(final) is not None + and objective is not None, + "lifecycle": _lifecycle( + ("adopted", initial, "internal_objective_weight", "direct_python_runtime"), + ("evolved", final, "internal_objective_weight", "direct_python_runtime"), + ), + } + + +def _routability_observation(_requested, params, _engine, _ppa, probe, iterations): + rounds = probe.get("routability_branch_round_count") + configured = _scalar_value(getattr(params, "routability_opt_flag", None)) + place_objects = probe.get("place_object_count", 0) + return { + "configured_routability_opt": configured, + "operator_constructed": probe.get("routability_operator_constructed", False), + "branch_round_count": rounds, + "place_object_count": place_objects, + "placement_iteration_count": iterations, + "evidence_complete": type(rounds) is int and place_objects > 0, + "lifecycle": _lifecycle( + ("adopted", configured, "boolean", "direct_python_runtime"), + ("consumed", rounds, "branch_round_count", "direct_python_runtime"), + ), + } + + +def _dreamplace_effective_values(knob_id, params, observation) -> tuple[Any, Any, str]: + if knob_id == "place.target_density": + value = observation["effective_target_density"] + return value, value, "ratio" + if knob_id == "place.target_overflow": + value = observation["effective_stop_overflow"] + return value, value, "ratio" + if knob_id == "place.cell_padding_x": + value = observation["effective_padding_dbu"] + return value, value, "dbu" + if knob_id == "place.density_weight": + return ( + _runtime_scalar(observation["internal_initial_density_weight"]), + _runtime_scalar(observation["final_internal_density_weight"]), + "internal_objective_weight", + ) + value = _scalar_value(getattr(params, "routability_opt_flag", None)) + return value, value, "boolean" + + +def _dreamplace_activation_status( + knob_id: str, + effective: Any, + observation: dict[str, Any], + *, + engine_succeeded: bool, +) -> str: + if not engine_succeeded or not observation.get("evidence_complete"): + return "unknown" + if knob_id == "place.routability_opt" and ( + effective in (False, 0) or not observation.get("branch_round_count") + ): + return "not_activated" + if knob_id == "place.cell_padding_x" and effective == 0: + return "not_activated" + return "used" + + +def _dreamplace_transitions(knob_id, requested, effective, evidence, observation): + if ( + knob_id == "place.target_density" + and isinstance(effective, (int, float)) + and effective > requested + ): + return [_transition("materialized", "overridden", effective, evidence)] + normalized = observation.get("normalized_padding_dbu") + if knob_id == "place.cell_padding_x" and (normalized is not None and effective != normalized): + return [_transition("normalized", "clamped", effective, evidence)] + return [] + + +def _transition(source: str, target: str, value: Any, evidence: dict) -> dict: + transition = { + "sequence": 0, + "from": source, + "to": target, + "value": value, + "reason": { + "overridden": "DREAMPlace utilization lower bound", + "clamped": "DREAMPlace movable-area padding cap", + }[target], + "evidence_ref": RUNTIME_REPORT_REF, + "evidence_sha256": evidence["evidence_sha256"], + } + if target == "overridden": + transition["rule_id"] = "dreamplace.target_density.utilization_floor" + return transition + + +def _dreamplace_consumer(knob_id: str) -> str: + return { + "place.target_density": "dreamplace.density_objective", + "place.target_overflow": "dreamplace.overflow_predicate", + "place.cell_padding_x": "dreamplace.cell_size_expansion", + "place.routability_opt": "dreamplace.routability_branch", + "place.density_weight": "dreamplace.density_preconditioner", + }[knob_id] + + +def _lifecycle(*events: tuple[str, Any, str, str]) -> list[dict[str, Any]]: + return [ + { + "sequence": sequence, + "phase": phase, + "value": value, + "unit": unit, + "evidence_kind": evidence_kind, + } + for sequence, (phase, value, unit, evidence_kind) in enumerate(events) + ] + + +def _native_overflow_values(engine: Any) -> list[float]: + metrics = getattr(engine, "metrics", None) + values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] + return [value for item in values if (value := _scalar_value(item)) is not None] + + +def _native_value(value: Any): + for operation in ("detach", "cpu", "tolist"): + with suppress(AttributeError, RuntimeError, TypeError, ValueError): + value = getattr(value, operation)() + if isinstance(value, list): + values = [_native_value(item) for item in value] + return values[0] if len(values) == 1 else values + with suppress(AttributeError, RuntimeError, TypeError, ValueError): + value = value.item() + return _finite_scalar(value) + + +def _scalar_value(value: Any): + with suppress(AttributeError, RuntimeError, TypeError, ValueError): + value = value.item() + return _finite_scalar(value) + + +def _finite_scalar(value: Any): + if type(value) is bool or type(value) is int: + return value + if type(value) is float and math.isfinite(value): + return value + return None + + +def _runtime_scalar(value: Any): + return value if type(value) in {int, float} and math.isfinite(value) else None + + +def _valid_iterations(value: Any) -> bool: + return type(value) is int and value > 0 + + +def _same_number(left: Any, right: Any) -> bool: + return ( + isinstance(left, (int, float)) + and isinstance(right, (int, float)) + and math.isclose(left, right, rel_tol=1e-6, abs_tol=1e-7) + ) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 72f902880..58d4c7a81 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -20,10 +20,10 @@ from chipcompiler.runtime.workspace_api import RuntimeApiError HASH = "sha256:" + "a" * 64 -PRODUCER = Path(__file__).parents[2] / "chipcompiler/tools/ecc_dreamplace/module.py" +PRODUCER = Path(__file__).parents[1] / "data/parameter_runtime_observer.py" TOOL = { "name": "DREAMPlace", - "revision": "ecc.dreamplace.parameter_runtime_report.v2", + "revision": "ecc.agent.dreamplace_parameter_observer.v1", "source_sha256": sha256_path(PRODUCER), } diff --git a/agent/test/test_parameter_runtime_observer.py b/agent/test/test_parameter_runtime_observer.py new file mode 100644 index 000000000..1f9ba767a --- /dev/null +++ b/agent/test/test_parameter_runtime_observer.py @@ -0,0 +1,374 @@ +import json +from contextlib import ExitStack +from threading import Thread +from types import SimpleNamespace + +from agent.data.floorplan_parameter_observer import build_floorplan_report +from agent.data.parameter_runtime_observer import ( + DreamplaceRecorder, + _build_dreamplace_report, + _invoke_and_record, + _native_value, + _observe_native_model, + _patch_method, +) + + +class _Scalar: + def __init__(self, value): + self.value = value + + def item(self): + return self.value + + +def _engine(*, params, target_density=0.8, padding_sites=2): + return SimpleNamespace( + params=params, + placer=SimpleNamespace( + data_collections=SimpleNamespace(target_density=_Scalar(target_density)) + ), + placedb=SimpleNamespace( + cell_padding_x=padding_sites, + num_movable_nodes=12, + ), + metrics={"overflow": [0.7, _Scalar(0.12), 0.08]}, + ) + + +def test_density_weight_report_tracks_internal_values_not_only_configured_value(tmp_path): + params = SimpleNamespace(density_weight=0.001) + probe = { + "density_weight_initializations": [0.004], + "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], + "final_internal_density_weight": 0.009, + } + + report = _build_dreamplace_report( + {"knob_id": "place.density_weight", "value": 0.001}, + _engine(params=params), + {"iteration": 5, "objective": 12.5}, + probe, + engine_succeeded=True, + ) + + observation = report["consumer_observation"] + assert observation["configured_density_weight"] == 0.001 + assert observation["internal_initial_density_weight"] == 0.004 + assert observation["final_internal_density_weight"] == 0.009 + assert report["effective_initial"] == { + "value": 0.004, + "unit": "internal_objective_weight", + } + assert report["effective_final"] == { + "value": 0.009, + "unit": "internal_objective_weight", + } + assert observation["lifecycle"] == [ + { + "sequence": 0, + "phase": "adopted", + "value": 0.004, + "unit": "internal_objective_weight", + "evidence_kind": "direct_python_runtime", + }, + { + "sequence": 1, + "phase": "evolved", + "value": 0.009, + "unit": "internal_objective_weight", + "evidence_kind": "direct_python_runtime", + }, + ] + + +def test_target_density_report_requires_operator_call_and_records_floor_override(tmp_path): + params = SimpleNamespace(target_density=0.65) + report = _build_dreamplace_report( + {"knob_id": "place.target_density", "value": 0.2}, + _engine(params=params, target_density=0.6499999761581421), + {"iteration": 4}, + {"density_operator_call_count": 3}, + engine_succeeded=True, + ) + + assert report["activation"]["status"] == "used" + assert report["effective_initial"] == {"value": 0.65, "unit": "ratio"} + assert report["transitions"] == [ + { + "sequence": 0, + "from": "materialized", + "to": "overridden", + "value": 0.65, + "reason": "DREAMPlace utilization lower bound", + "rule_id": "dreamplace.target_density.utilization_floor", + "evidence_ref": "analysis/parameter_runtime_report.v1.json", + "evidence_sha256": report["activation"]["consumers"][0]["evidence_sha256"], + } + ] + + +def test_target_overflow_report_binds_threshold_to_running_predicate_owner(tmp_path): + params = SimpleNamespace(stop_overflow=0.1) + report = _build_dreamplace_report( + {"knob_id": "place.target_overflow", "value": 0.1}, + _engine(params=params), + {"iteration": 7, "overflow": 0.08}, + { + "nonlinear_place_call_count": 1, + "stop_overflow_read_count": 4, + }, + engine_succeeded=True, + ) + + observation = report["consumer_observation"] + assert report["activation"]["status"] == "used" + assert report["activation"]["consumers"][0]["outcome"] == "evaluated" + assert observation["predicate_owner_call_count"] == 1 + assert observation["threshold_read_count"] == 4 + assert observation["observed_overflow_count"] == 3 + assert observation["threshold_reached"] is True + assert observation["lifecycle"][1]["evidence_kind"] == "direct_python_runtime" + + +def test_routability_report_distinguishes_disabled_gate_from_entered_branch(tmp_path): + disabled = _build_dreamplace_report( + {"knob_id": "place.routability_opt", "value": False}, + _engine(params=SimpleNamespace(routability_opt_flag=False)), + {"iteration": 3}, + { + "place_object_count": 1, + "routability_operator_constructed": False, + "routability_branch_round_count": 0, + }, + engine_succeeded=True, + ) + entered = _build_dreamplace_report( + {"knob_id": "place.routability_opt", "value": True}, + _engine(params=SimpleNamespace(routability_opt_flag=True)), + {"iteration": 3}, + { + "place_object_count": 1, + "routability_operator_constructed": True, + "routability_branch_round_count": 1, + }, + engine_succeeded=True, + ) + + assert disabled["activation"]["status"] == "not_activated" + assert disabled["activation"]["consumers"][0]["outcome"] == "evaluated" + assert entered["activation"]["status"] == "used" + assert entered["consumer_observation"]["branch_round_count"] == 1 + + +def test_padding_report_keeps_written_consumed_internal_and_restored_values_distinct( + tmp_path, +): + params = SimpleNamespace(cell_padding_x=0) + probe = { + "cell_padding": { + "normalized_padding_dbu": 400, + "effective_padding_dbu": 200, + "geometry_apply_count": 1, + } + } + + report = _build_dreamplace_report( + {"knob_id": "place.cell_padding_x", "value": 400}, + _engine(params=params, padding_sites=1), + {"iteration": 3}, + probe, + engine_succeeded=True, + ) + + assert report["effective_initial"] == {"value": 200, "unit": "dbu"} + assert report["effective_final"] == {"value": 200, "unit": "dbu"} + assert report["consumer_observation"] == { + "requested_padding_dbu": 400, + "normalized_padding_dbu": 400, + "effective_padding_dbu": 200, + "effective_padding_sites": 1, + "post_legalization_padding_sites": 0, + "representation_restored": True, + "geometry_apply_count": 1, + "movable_node_count": 12, + "placement_iteration_count": 3, + "evidence_complete": True, + "lifecycle": [ + { + "sequence": 0, + "phase": "normalized", + "value": 400, + "unit": "dbu", + "evidence_kind": "direct_python_runtime", + }, + { + "sequence": 1, + "phase": "consumed", + "value": 200, + "unit": "dbu", + "evidence_kind": "direct_python_runtime", + }, + { + "sequence": 2, + "phase": "restored", + "value": 0, + "unit": "internal_site", + "evidence_kind": "post_run_state", + }, + ], + } + + +def test_floorplan_report_separates_boundary_value_from_realized_geometry(tmp_path): + config_path = tmp_path / "floorplan.json" + config_path.write_text( + json.dumps( + { + "die_builder": { + "mode": "die_util", + "die_util": {"utilization": 0.8, "aspect_ratio": 1.0}, + } + } + ), + encoding="utf-8", + ) + feature_path = tmp_path / "feature.json" + feature_path.write_text( + json.dumps( + { + "Design Layout": { + "core_area": 800.0, + "core_usage": 0.79, + "core_bounding_width": 40.0, + "core_bounding_height": 20.0, + } + } + ), + encoding="utf-8", + ) + boundary = { + "init_fp_call_count": 1, + "run_fp_call_count": 1, + "config_path": str(config_path), + } + + report = build_floorplan_report( + {"knob_id": "floorplan.core_util", "value": 0.8}, + boundary, + feature_path, + engine_succeeded=True, + ) + + assert report["effective_initial"] == {"value": 0.8, "unit": "ratio"} + assert report["effective_final"] == {"value": 0.79, "unit": "ratio"} + observation = report["consumer_observation"] + assert observation["evidence_kind"] == "boundary_and_derived_output" + assert observation["realized_core_utilization"] == 0.79 + assert observation["realized_aspect_ratio"] == 2.0 + assert observation["lifecycle"][-1] == { + "sequence": 2, + "phase": "realized", + "value": 0.79, + "unit": "ratio", + "evidence_kind": "derived_verified_artifact", + } + + +def test_scoped_method_hook_is_restored_after_candidate(): + class Owner: + def run(self): + return "original" + + original = Owner.run + with ExitStack() as stack: + _patch_method( + stack, + Owner, + "run", + lambda wrapped, owner: (wrapped(owner), "observed"), + ) + assert Owner().run() == ("original", "observed") + assert Owner.run is not original + foreign_result = [] + thread = Thread(target=lambda: foreign_result.append(Owner().run())) + thread.start() + thread.join() + assert foreign_result == ["original"] + + assert Owner.run is original + + +def test_native_model_hook_records_density_updates_and_routability_calls(): + recorder = DreamplaceRecorder( + patch={"knob_id": "place.density_weight", "value": 0.001}, + ) + model = SimpleNamespace(density_weight=0.0) + + def initialize_density_weight(): + model.density_weight = 0.004 + return model.density_weight + + def update_density_weight(): + model.density_weight = 0.006 + return "updated" + + model.initialize_density_weight = initialize_density_weight + model.op_collections = SimpleNamespace( + update_density_weight_op=update_density_weight, + adjust_node_area_op=lambda: "adjusted", + ) + + with ExitStack() as stack: + _observe_native_model(model, recorder, stack) + assert model.initialize_density_weight() == 0.004 + assert model.op_collections.update_density_weight_op() == "updated" + assert recorder.probe["density_weight_initializations"] == [0.004] + assert recorder.probe["density_weight_updates"] == [ + {"sequence": 0, "before": 0.004, "after": 0.006} + ] + + +def test_native_value_preserves_vectors_and_drops_non_finite_values(): + assert _native_value([_Scalar(0.1), _Scalar(0.2)]) == [0.1, 0.2] + assert _native_value(float("inf")) is None + + +def test_density_weight_vector_is_preserved_without_claiming_scalar_effectiveness(): + report = _build_dreamplace_report( + {"knob_id": "place.density_weight", "value": 0.001}, + _engine(params=SimpleNamespace(density_weight=0.001)), + {"iteration": 4, "objective": 1.0}, + { + "density_weight_initializations": [[0.004, 0.005]], + "density_weight_updates": [], + "final_internal_density_weight": [0.006, 0.007], + }, + engine_succeeded=True, + ) + + assert report["activation"]["status"] == "unknown" + assert report["effective_initial"]["value"] is None + assert report["consumer_observation"]["final_internal_density_weight"] == [ + 0.006, + 0.007, + ] + + +def test_report_failure_does_not_change_tool_result(monkeypatch, tmp_path): + failures = [] + workspace = SimpleNamespace( + directory=tmp_path, + logger=SimpleNamespace(exception=lambda message: failures.append(message)), + ) + + def fail_write(*_args, **_kwargs): + raise OSError("read-only analysis directory") + + monkeypatch.setattr( + "agent.data.parameter_runtime_observer.write_json_atomic", + fail_write, + ) + + assert _invoke_and_record(workspace, lambda: True, lambda _ok: {}) is True + assert failures == ["Failed to persist parameter runtime evidence"] diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index c0fd7e495..f652db588 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -1,8 +1,10 @@ import json +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace from agent import tools as eda +from agent.data import parameter_runtime_observer as runtime_observer from agent.data.candidate_materialization import materialize_candidate_config @@ -11,6 +13,14 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data), encoding="utf-8") +class _Scalar: + def __init__(self, value): + self.value = value + + def item(self): + return self.value + + def test_tool_runner_reapplies_candidate_overlay_after_builder_refresh(monkeypatch, tmp_path): config_path = tmp_path / "config" / "dreamplace_ecc.json" _write_json(config_path, {"target_density": 0.8}) @@ -46,6 +56,53 @@ def run_step(workspace, step, ecc_module): assert consumed == [0.65] +def test_tool_runner_owns_candidate_runtime_report(monkeypatch, tmp_path): + config_path = tmp_path / "config" / "dreamplace_ecc.json" + _write_json(config_path, {"target_density": 0.8}) + workspace = SimpleNamespace( + directory=str(tmp_path), + config={"dreamplace": config_path}, + pdk=SimpleNamespace(), + logger=SimpleNamespace(exception=lambda *_args, **_kwargs: None), + flow=SimpleNamespace(data={"steps": [{"name": "place", "tool": "dreamplace"}]}), + ) + step = SimpleNamespace(name="place", tool="dreamplace") + materialize_candidate_config( + workspace, + "place", + [{"knob_id": "place.target_density", "value": 0.65}], + candidate_id="place-rerun-observed", + ) + recorder = runtime_observer.DreamplaceRecorder( + patch={"knob_id": "place.target_density", "value": 0.65} + ) + + def run_step(**_kwargs): + recorder.engine = SimpleNamespace( + params=SimpleNamespace(target_density=0.65), + placer=SimpleNamespace(data_collections=SimpleNamespace(target_density=_Scalar(0.65))), + ) + recorder.ppa = {"iteration": 3} + recorder.probe["density_operator_call_count"] = 2 + return True + + tool = SimpleNamespace(build_step_config=lambda *_args: None, run_step=run_step) + monkeypatch.setattr(eda, "load_eda_module", lambda *_args, **_kwargs: tool) + monkeypatch.setattr(eda, "log_workspace_step", lambda *_args, **_kwargs: None) + + @contextmanager + def capture(_patch): + yield recorder + + monkeypatch.setattr(runtime_observer, "_capture_dreamplace", capture) + + assert eda.run_step(workspace, step, ecc_module=True) is True + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) + assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v1" + assert report["activation"]["status"] == "used" + assert report["consumer_observation"]["density_operator_call_count"] == 2 + + def test_legalization_runner_reapplies_real_dreamplace_overlay(monkeypatch, tmp_path): config_path = tmp_path / "config" / "dreamplace_ecc.json" _write_json(config_path, {"bndry_padding_x": 0}) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 6108829d9..b50f53839 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -427,7 +427,7 @@ def test_failed_candidate_returns_materialization_application_and_manifest_evide monkeypatch.setattr("agent.workspace_api._reapply_candidate_input", lambda *_args: None) tool = { "name": "DREAMPlace", - "revision": "ecc.dreamplace.parameter_runtime_report.v2", + "revision": "ecc.agent.dreamplace_parameter_observer.v1", "source_sha256": "sha256:" + "3" * 64, } diff --git a/agent/tools.py b/agent/tools.py index 2c36c1add..1cca11dd3 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -2,6 +2,7 @@ from chipcompiler.tools.eda import load_eda_module from .data import reapply_materialized_candidate_config +from .data.parameter_runtime_observer import run_with_parameter_observation def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: @@ -9,6 +10,11 @@ def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool if eda_module is None: return False eda_module.build_step_config(workspace, step) - reapply_materialized_candidate_config(workspace, step.name) + materialization = reapply_materialized_candidate_config(workspace, step.name) log_workspace_step(step, workspace.logger) - return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) + return run_with_parameter_observation( + workspace, + step, + materialization, + lambda: eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module), + ) diff --git a/chipcompiler/tools/ecc/parameter_runtime_report.py b/chipcompiler/tools/ecc/parameter_runtime_report.py deleted file mode 100644 index 7216c842c..000000000 --- a/chipcompiler/tools/ecc/parameter_runtime_report.py +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env python - -import hashlib -import json -import os -import re -from math import isfinite -from pathlib import Path - -from chipcompiler.data import Workspace - -CTS_RUNTIME_REPORT_REVISION = "ecc.cts.parameter_runtime_report.v1" -FLOORPLAN_RUNTIME_REPORT_REVISION = "ecc.floorplan.parameter_runtime_report.v2" -_RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" - - -def _source_sha256() -> str: - return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() - - -def _candidate_patch(workspace_dir: Path, knob_ids: set[str]) -> dict | None: - path = workspace_dir / "analysis" / "candidate_materialization.v1.json" - if not path.is_file(): - return None - try: - materialization = json.loads(path.read_text(encoding="utf-8")) - return next(item for item in materialization["patch"] if item.get("knob_id") in knob_ids) - except (OSError, ValueError, KeyError, TypeError, StopIteration): - return None - - -def _write_runtime_report(workspace_dir: Path, report: dict) -> None: - output_path = workspace_dir / _RUNTIME_REPORT_REF - output_path.parent.mkdir(parents=True, exist_ok=True) - temporary = output_path.with_name(output_path.name + ".tmp") - temporary.write_text( - json.dumps(report, sort_keys=True, separators=(",", ":")), encoding="utf-8" - ) - os.replace(temporary, output_path) - - -def _write_cts_parameter_runtime_report( - workspace: Workspace, - config_path: str | Path, - *, - engine_succeeded: bool, -) -> None: - """Record CTS config effectiveness without claiming unobserved activation.""" - workspace_dir = getattr(workspace, "directory", None) - if workspace_dir is None: - return - workspace_dir = Path(workspace_dir) - patch = _candidate_patch(workspace_dir, {"cts.max_fanout"}) - if patch is None: - return - try: - value = json.loads(Path(config_path).read_text(encoding="utf-8"))["max_fanout"] - except (OSError, ValueError, KeyError, TypeError): - return - - requested = patch.get("value") - matches_request = type(value) is int and value == requested - effective = value if engine_succeeded else None - report = { - "knob_id": "cts.max_fanout", - "requested_value": requested, - "tool": { - "name": "ECC-CTS", - "revision": CTS_RUNTIME_REPORT_REVISION, - "source_sha256": _source_sha256(), - }, - "application_status": ("applied" if engine_succeeded and matches_request else "unknown"), - "effective_initial": {"value": effective, "unit": "fanout"}, - "effective_final": {"value": effective, "unit": "fanout"}, - "activation": {"status": "unknown", "consumers": []}, - "consumer_observation": { - "config_value": value, - "engine_succeeded": engine_succeeded, - "activation_evidence_complete": False, - }, - "transitions": [], - } - _write_runtime_report(workspace_dir, report) - - -def _write_floorplan_parameter_runtime_report( - workspace: Workspace, - config_path: str | Path, - *, - feature_path: str | Path | None = None, - report_path: str | Path | None = None, -) -> None: - """Record the candidate knob consumed by iFP's native die builder.""" - workspace_dir = getattr(workspace, "directory", None) - if workspace_dir is None: - return - workspace_dir = Path(workspace_dir) - patch = _candidate_patch(workspace_dir, {"floorplan.core_util", "floorplan.aspect_ratio"}) - if patch is None: - return - try: - floorplan = json.loads(Path(config_path).read_text(encoding="utf-8")) - die_builder = floorplan["die_builder"] - die_util = die_builder["die_util"] - except (OSError, ValueError, KeyError, TypeError): - return - - knob_id = patch["knob_id"] - field, consumer_id = { - "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), - "floorplan.aspect_ratio": ("aspect_ratio", "ifp.die_builder.die_aspect_ratio"), - }[knob_id] - value = die_util.get(field) - requested = patch.get("value") - mode = die_builder.get("mode") - matches_request = value == requested - observation = _floorplan_geometry_observation(feature_path, report_path) - complete = _floorplan_observation_complete(observation) - status = ( - "used" - if complete and mode == "die_util" and value is not None and matches_request - else "unknown" - ) - if complete and mode != "die_util" and value is not None and matches_request: - status = "not_activated" - evidence = { - "consumer_id": consumer_id, - "outcome": "geometry_constructed" if status == "used" else "evaluated", - "evidence_ref": _RUNTIME_REPORT_REF, - } - evidence["evidence_sha256"] = ( - "sha256:" - + hashlib.sha256( - json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - ) - report = { - "knob_id": knob_id, - "requested_value": requested, - "tool": { - "name": "ECC-Floorplan", - "revision": FLOORPLAN_RUNTIME_REPORT_REVISION, - "source_sha256": _source_sha256(), - }, - "application_status": "applied" if complete and matches_request else "unknown", - "effective_initial": {"value": value, "unit": "ratio"}, - "effective_final": {"value": value, "unit": "ratio"}, - "activation": { - "status": status, - "consumers": [evidence] if status in {"used", "not_activated"} else [], - }, - "transitions": [], - } - if observation is not None: - report["consumer_observation"] = observation - if feature_path is not None and not complete: - report["application_status"] = "unknown" - report["activation"] = {"status": "unknown", "consumers": []} - _write_runtime_report(workspace_dir, report) - - -def _floorplan_geometry_observation( - feature_path: str | Path | None, report_path: str | Path | None -) -> dict | None: - if not feature_path or not Path(feature_path).is_file(): - return None - try: - feature = json.loads(Path(feature_path).read_text(encoding="utf-8")) - layout = feature["Design Layout"] - width = layout["core_bounding_width"] - height = layout["core_bounding_height"] - area = layout.get("core_area") - except (OSError, ValueError, KeyError, TypeError): - return None - numeric = all(isinstance(item, (int, float)) and isfinite(item) for item in (width, height)) - if not numeric or width <= 0 or height <= 0: - return None - ratio = width / height - rows, sites = _floorplan_report_counts(report_path) - return { - "core_geometry": { - "width": {"value": width, "unit": "um"}, - "height": {"value": height, "unit": "um"}, - "area": {"value": area, "unit": "um^2"}, - "aspect_ratio": {"value": ratio, "unit": "ratio"}, - }, - "rows": {"count": rows, "observed": rows is not None}, - "sites": {"count": sites, "observed": sites is not None}, - } - - -def _floorplan_observation_complete(observation: dict | None) -> bool: - if not observation: - return False - geometry = observation.get("core_geometry", {}) - return all( - geometry.get(name, {}).get("value") is not None - for name in ("width", "height", "area", "aspect_ratio") - ) and ( - observation.get("rows", {}).get("observed") is True - and observation.get("sites", {}).get("observed") is True - ) - - -def _floorplan_report_counts(report_path: str | Path | None) -> tuple[int | None, int | None]: - if not report_path or not Path(report_path).is_file(): - return None, None - try: - text = Path(report_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return None, None - values = {} - for name in ("Site", "Row"): - match = re.search(rf"Number\s*-\s*{name}[^0-9]*(\d+)", text) - if match: - values[name] = int(match.group(1)) - return values.get("Row"), values.get("Site") diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 66f0b3878..018d3c921 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -18,10 +18,6 @@ save_rcx_spef_feature_facts, ) from chipcompiler.tools.ecc.module import ECCToolsModule -from chipcompiler.tools.ecc.parameter_runtime_report import ( - _write_cts_parameter_runtime_report, - _write_floorplan_parameter_runtime_report, -) from chipcompiler.tools.ecc.plot import ECCToolsPlot from chipcompiler.tools.ecc.sta_artifacts import discard_sta_outputs from chipcompiler.tools.ecc.sta_qor import ( @@ -570,9 +566,6 @@ def run_cts(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No config=config_path, output=(step.data.steps or {}).get(StepEnum.CTS.value, ""), ) - _write_cts_parameter_runtime_report( - workspace, config_path, engine_succeeded=bool(engine_succeeded) - ) if not engine_succeeded: return False @@ -779,12 +772,6 @@ def run_floorplan( feature_step=False, report_timing=False, ) - _write_floorplan_parameter_runtime_report( - workspace, - workspace.config.get(StepEnum.FLOORPLAN.value, ""), - feature_path=step.feature.db, - report_path=step.report.db, - ) sub_flow.update_step(step_name=EccSubFlowEnum.save_data.value, state=StateEnum.Success) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index e9163cd09..d5433333e 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -9,10 +9,6 @@ from chipcompiler.data import StepEnum, Workspace, WorkspaceStep from chipcompiler.tools.ecc.module import ECCToolsModule -from chipcompiler.tools.ecc_dreamplace.parameter_runtime_report import ( - _capture_native_runtime, - _write_parameter_runtime_report, -) from chipcompiler.utility.path import optional_path, path_text _LEGALIZE_OWNERS = frozenset( @@ -120,27 +116,13 @@ def _run(self, *, legalize_only: bool) -> bool: engine = PlacementEngine(params) engine.setup_rawdb(ecc_module=self.ecc_module) - with _capture_native_runtime(self.workspace) as native_runtime_probe: - ppa = engine.run() - engine.native_runtime_probe = native_runtime_probe + ppa = engine.run() if ppa.get("hpwl") == float("inf"): - if not legalize_only: - _write_parameter_runtime_report( - self.workspace, engine.params, engine=engine, ppa=ppa - ) LOGGER = logging.getLogger(__name__) LOGGER.error("dreamplace failed for %s", self.step.name) return False - if not legalize_only: - _write_parameter_runtime_report( - self.workspace, - engine.params, - engine=engine, - ppa=ppa, - engine_succeeded=True, - ) return True def run_placement(self) -> bool: diff --git a/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py b/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py deleted file mode 100644 index c770f8392..000000000 --- a/chipcompiler/tools/ecc_dreamplace/parameter_runtime_report.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env python - -import hashlib -import json -from contextlib import contextmanager, suppress -from pathlib import Path - -from chipcompiler.data import Workspace -from chipcompiler.utility.json import json_write - -DREAMPLACE_RUNTIME_REPORT_REVISION = "ecc.dreamplace.parameter_runtime_report.v2" -_NATIVE_PROBE_KNOBS = frozenset( - { - "place.density_weight", - "place.routability_opt", - } -) - - -def _runtime_unit(knob_id: str) -> str: - if knob_id.endswith("routability_opt"): - return "boolean" - if knob_id.endswith("cell_padding_x"): - return "dbu" - if knob_id.endswith("density_weight"): - return "objective_weight" - return "ratio" - - -def _write_parameter_runtime_report( - workspace: Workspace, - params, - *, - engine=None, - ppa: dict | None = None, - engine_succeeded: bool = False, -) -> None: - """Record the selected candidate knob at the native DreamPlace boundary.""" - workspace_dir = workspace.directory - if workspace_dir is None: - return - patch = _candidate_patch(workspace) - if patch is None: - return - knob_id = patch.get("knob_id") - consumer_by_knob = { - "place.target_density": "dreamplace.density_objective", - "place.target_overflow": "dreamplace.overflow_predicate", - "place.cell_padding_x": "dreamplace.cell_size_expansion", - "place.routability_opt": "dreamplace.routability_branch", - "place.density_weight": "dreamplace.density_preconditioner", - } - if knob_id not in consumer_by_knob: - return - consumer_id = consumer_by_knob[knob_id] - observation = _consumer_observation(knob_id, patch.get("value"), params, engine, ppa) - value = _effective_value(knob_id, params, observation) - status = _activation_status(knob_id, value, observation, engine_succeeded=engine_succeeded) - outcome = "evaluated" if knob_id == "place.target_overflow" or status != "used" else "entered" - evidence_payload = { - "consumer_id": consumer_id, - "outcome": outcome, - "consumer_observation": observation, - } - evidence = { - "consumer_id": consumer_id, - "outcome": outcome, - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": _payload_sha256(evidence_payload), - } - report = { - "knob_id": knob_id, - "requested_value": patch.get("value"), - "tool": { - "name": "DREAMPlace", - "revision": DREAMPLACE_RUNTIME_REPORT_REVISION, - "source_sha256": _source_sha256(), - }, - "application_status": "applied" if value is not None else "unknown", - "effective_initial": {"value": value, "unit": _runtime_unit(knob_id)}, - "effective_final": {"value": value, "unit": _runtime_unit(knob_id)}, - "activation": {"status": status, "consumers": [evidence] if status != "unknown" else []}, - "transitions": ( - _runtime_transitions(knob_id, patch.get("value"), value, evidence) - if status == "used" - else [] - ), - "consumer_observation": observation, - } - report_path = Path(workspace_dir) / "analysis" / "parameter_runtime_report.v1.json" - report_path.parent.mkdir(parents=True, exist_ok=True) - if not json_write(report_path, report, indent=None): - raise OSError(f"Failed to write DreamPlace parameter runtime report: {report_path}") - - -def _candidate_patch(workspace: Workspace) -> dict | None: - workspace_dir = workspace.directory - if workspace_dir is None: - return None - path = Path(workspace_dir) / "analysis" / "candidate_materialization.v1.json" - if not path.is_file(): - return None - try: - return json.loads(path.read_text(encoding="utf-8"))["patch"][0] - except (OSError, ValueError, KeyError, IndexError, TypeError): - return None - - -def _consumer_observation(knob_id, requested, params, engine, ppa) -> dict: - ppa = ppa if isinstance(ppa, dict) else {} - iterations = ppa.get("iteration") - valid_iterations = type(iterations) is int and iterations > 0 - if knob_id == "place.target_density": - data = getattr( - getattr(getattr(engine, "placer", None), "data_collections", None), - "target_density", - None, - ) - tensor_value = _scalar_value(data) - effective = _scalar_value(getattr(params, "target_density", None)) - return { - "requested_target_density": requested, - "effective_target_density": effective, - "density_tensor_value": tensor_value, - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations and tensor_value == effective, - } - if knob_id == "place.target_overflow": - overflows = _native_overflow_values(engine) - threshold = _scalar_value(getattr(params, "stop_overflow", None)) - minimum = min(overflows) if overflows else None - return { - "effective_stop_overflow": threshold, - "final_overflow": _scalar_value(ppa.get("overflow")), - "placement_iteration_count": iterations, - "comparison_count": len(overflows), - "minimum_observed_overflow": minimum, - "threshold_reached": minimum <= threshold - if minimum is not None and threshold is not None - else None, - "evidence_complete": valid_iterations and bool(overflows) and threshold is not None, - } - if knob_id == "place.cell_padding_x": - placedb = getattr(engine, "placedb", None) - effective = _scalar_value(getattr(placedb, "cell_padding_x", None)) - movable = getattr(placedb, "num_movable_nodes", None) - return { - "requested_padding_dbu": requested, - "effective_padding_dbu": effective, - "movable_node_count": movable, - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations - and effective is not None - and type(movable) is int, - } - if knob_id == "place.density_weight": - probe = _native_runtime_probe(engine) - initializations = probe.get("density_weight_initializations", []) - updates = probe.get("density_weight_updates", []) - initial = initializations[0] if initializations else None - final = ( - updates[-1]["after"] if updates else initializations[-1] if initializations else None - ) - return { - "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), - "internal_initial_density_weight": initial, - "density_weight_updates": updates, - "density_weight_update_count": len(updates), - "final_internal_density_weight": final, - "final_objective": _scalar_value(ppa.get("objective")), - "placement_iteration_count": iterations, - "evidence_complete": valid_iterations - and initial is not None - and _scalar_value(ppa.get("objective")) is not None, - } - rounds = _native_runtime_probe(engine).get("routability_branch_round_count") - return {"branch_round_count": rounds, "evidence_complete": isinstance(rounds, int)} - - -def _effective_value(knob_id: str, params, observation: dict): - if knob_id == "place.target_density": - return observation["effective_target_density"] - if knob_id == "place.cell_padding_x": - return observation["effective_padding_dbu"] - key = { - "place.target_overflow": "stop_overflow", - "place.routability_opt": "routability_opt_flag", - "place.density_weight": "density_weight", - }[knob_id] - return _scalar_value(getattr(params, key, None)) - - -def _activation_status(knob_id: str, value, observation: dict, *, engine_succeeded: bool) -> str: - if not engine_succeeded or not observation.get("evidence_complete"): - return "unknown" - if knob_id == "place.routability_opt" and value in (False, 0): - return "not_activated" - if knob_id == "place.routability_opt" and not observation.get("branch_round_count"): - return "not_activated" - if knob_id == "place.cell_padding_x" and value == 0: - return "not_activated" - return "used" - - -def _runtime_transitions(knob_id: str, requested, effective, evidence: dict) -> list[dict]: - if knob_id != "place.target_density" or not isinstance(requested, (int, float)): - return [] - if not isinstance(effective, (int, float)) or effective <= requested: - return [] - return [ - { - "sequence": 0, - "from": "materialized", - "to": "overridden", - "value": effective, - "reason": "DREAMPlace utilization lower bound", - "rule_id": "dreamplace.target_density.utilization_floor", - "evidence_ref": evidence["evidence_ref"], - "evidence_sha256": evidence["evidence_sha256"], - } - ] - - -def _scalar_value(value): - with suppress(AttributeError): - value = value.item() - return value if type(value) in {bool, int, float} else None - - -def _payload_sha256(payload: dict) -> str: - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() - return "sha256:" + hashlib.sha256(encoded).hexdigest() - - -def _source_sha256() -> str: - return "sha256:" + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() - - -def _native_runtime_probe(engine) -> dict: - probe = getattr(engine, "native_runtime_probe", None) - return probe if isinstance(probe, dict) else {} - - -def _native_overflow_values(engine) -> list[float]: - metrics = getattr(engine, "metrics", None) - values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] - return [value for item in values if (value := _scalar_value(item)) is not None] - - -def _native_numeric(value): - for operation in ("detach", "cpu", "tolist"): - with suppress(AttributeError): - value = getattr(value, operation)() - if type(value) in {int, float}: - return value - if isinstance(value, list) and value and all(type(item) in {int, float} for item in value): - return value - return None - - -@contextmanager -def _capture_native_runtime(workspace: Workspace): - patch = _candidate_patch(workspace) - if patch is None or patch.get("knob_id") not in _NATIVE_PROBE_KNOBS: - yield {} - return - - from dreamplace.PlaceObj import PlaceObj - - probe = { - "density_weight_initializations": [], - "density_weight_updates": [], - "routability_branch_round_count": 0, - } - original_init = PlaceObj.__init__ - - def observed_init(model, *args, **kwargs): - original_init(model, *args, **kwargs) - _observe_native_model(model, probe) - - PlaceObj.__init__ = observed_init - try: - yield probe - finally: - PlaceObj.__init__ = original_init - - -def _observe_native_model(model, probe: dict) -> None: - initialize = model.initialize_density_weight - - def observed_initialize(*args, **kwargs): - result = initialize(*args, **kwargs) - if (value := _native_numeric(result)) is not None: - probe["density_weight_initializations"].append(value) - return result - - model.initialize_density_weight = observed_initialize - operations = model.op_collections - update = getattr(operations, "update_density_weight_op", None) - if callable(update): - - def observed_update(*args, **kwargs): - before = _native_numeric(model.density_weight) - result = update(*args, **kwargs) - after = _native_numeric(model.density_weight) - probe["density_weight_updates"].append( - { - "sequence": len(probe["density_weight_updates"]), - "before": before, - "after": after, - } - ) - return result - - operations.update_density_weight_op = observed_update - adjust_area = getattr(operations, "adjust_node_area_op", None) - if callable(adjust_area): - - def observed_adjust_area(*args, **kwargs): - probe["routability_branch_round_count"] += 1 - return adjust_area(*args, **kwargs) - - operations.adjust_node_area_op = observed_adjust_area diff --git a/test/tools/ecc/test_cts_runtime_report.py b/test/tools/ecc/test_cts_runtime_report.py deleted file mode 100644 index 223bf5ab0..000000000 --- a/test/tools/ecc/test_cts_runtime_report.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from pathlib import Path -from types import SimpleNamespace - -from chipcompiler.tools.ecc.parameter_runtime_report import _write_cts_parameter_runtime_report - - -def _write_candidate(tmp_path: Path, value: int) -> None: - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "cts.max_fanout", "value": value}]}), - encoding="utf-8", - ) - - -def test_cts_runtime_report_records_effective_value_without_claiming_activation(tmp_path): - _write_candidate(tmp_path, 48) - config = tmp_path / "config" / "cts_ecc.json" - config.parent.mkdir() - config.write_text('{"max_fanout": 48}', encoding="utf-8") - - _write_cts_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), config, engine_succeeded=True - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - source = Path(_write_cts_parameter_runtime_report.__code__.co_filename) - assert report["tool"] == { - "name": "ECC-CTS", - "revision": "ecc.cts.parameter_runtime_report.v1", - "source_sha256": "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest(), - } - assert report["knob_id"] == "cts.max_fanout" - assert report["requested_value"] == 48 - assert report["application_status"] == "applied" - assert report["effective_final"] == {"value": 48, "unit": "fanout"} - assert report["activation"] == {"status": "unknown", "consumers": []} - assert report["consumer_observation"] == { - "config_value": 48, - "engine_succeeded": True, - "activation_evidence_complete": False, - } - - -def test_cts_runtime_report_rejects_mismatched_effective_value(tmp_path): - _write_candidate(tmp_path, 48) - config = tmp_path / "config" / "cts_ecc.json" - config.parent.mkdir() - config.write_text('{"max_fanout": 32}', encoding="utf-8") - - _write_cts_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), config, engine_succeeded=True - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["application_status"] == "unknown" - assert report["effective_final"] == {"value": 32, "unit": "fanout"} diff --git a/test/tools/ecc/test_floorplan_runtime_report.py b/test/tools/ecc/test_floorplan_runtime_report.py deleted file mode 100644 index b914997db..000000000 --- a/test/tools/ecc/test_floorplan_runtime_report.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from pathlib import Path -from types import SimpleNamespace - -from chipcompiler.tools.ecc.parameter_runtime_report import ( - _write_floorplan_parameter_runtime_report, -) - - -def _write_candidate(tmp_path: Path, knob_id: str, value: float) -> None: - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": knob_id, "value": value}]}), encoding="utf-8" - ) - - -def _write_config(tmp_path: Path, *, mode: str, field: str, value: float) -> Path: - config_path = tmp_path / "config" / "floorplan_ecc.json" - config_path.parent.mkdir() - config_path.write_text( - json.dumps({"die_builder": {"mode": mode, "die_util": {field: value}}}), - encoding="utf-8", - ) - return config_path - - -def _write_geometry_evidence(tmp_path: Path) -> tuple[Path, Path]: - feature_path = tmp_path / "feature.json" - feature_path.write_text( - json.dumps( - { - "Design Layout": { - "core_area": 800.0, - "core_bounding_width": 40.0, - "core_bounding_height": 20.0, - } - } - ), - encoding="utf-8", - ) - report_path = tmp_path / "report.rpt" - report_path.write_text("Number - Site | 120\nNumber - Row | 30\n", encoding="utf-8") - return feature_path, report_path - - -def test_runtime_report_records_native_core_utilization_consumer(tmp_path): - _write_candidate(tmp_path, "floorplan.core_util", 0.8) - config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) - feature_path, report_path = _write_geometry_evidence(tmp_path) - - _write_floorplan_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - config_path, - feature_path=feature_path, - report_path=report_path, - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - source_path = Path(_write_floorplan_parameter_runtime_report.__code__.co_filename) - assert report["tool"] == { - "name": "ECC-Floorplan", - "revision": "ecc.floorplan.parameter_runtime_report.v2", - "source_sha256": "sha256:" + hashlib.sha256(source_path.read_bytes()).hexdigest(), - } - assert report["activation"]["status"] == "used" - assert report["activation"]["consumers"][0]["consumer_id"] == ( - "ifp.die_builder.die_utilization" - ) - assert report["activation"]["consumers"][0]["outcome"] == "geometry_constructed" - assert report["effective_final"] == {"value": 0.8, "unit": "ratio"} - - -def test_runtime_report_records_native_aspect_ratio_consumer(tmp_path): - _write_candidate(tmp_path, "floorplan.aspect_ratio", 1.25) - config_path = _write_config(tmp_path, mode="die_util", field="aspect_ratio", value=1.25) - feature_path, report_path = _write_geometry_evidence(tmp_path) - - _write_floorplan_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - config_path, - feature_path=feature_path, - report_path=report_path, - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "used" - assert report["activation"]["consumers"][0]["consumer_id"] == ( - "ifp.die_builder.die_aspect_ratio" - ) - assert report["activation"]["consumers"][0]["outcome"] == "geometry_constructed" - - -def test_runtime_report_does_not_claim_used_without_geometry_evidence(tmp_path): - _write_candidate(tmp_path, "floorplan.core_util", 0.8) - config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) - - _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["application_status"] == "unknown" - assert report["activation"] == {"status": "unknown", "consumers": []} - - -def test_runtime_report_marks_die_size_mode_not_activated(tmp_path): - _write_candidate(tmp_path, "floorplan.core_util", 0.8) - config_path = _write_config(tmp_path, mode="die_size", field="utilization", value=0.8) - feature_path, report_path = _write_geometry_evidence(tmp_path) - - _write_floorplan_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - config_path, - feature_path=feature_path, - report_path=report_path, - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "not_activated" - - -def test_runtime_report_does_not_claim_mismatched_native_value(tmp_path): - _write_candidate(tmp_path, "floorplan.core_util", 0.8) - config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.7) - - _write_floorplan_parameter_runtime_report(SimpleNamespace(directory=tmp_path), config_path) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["application_status"] == "unknown" - assert report["activation"]["status"] == "unknown" - assert report["activation"]["consumers"] == [] - - -def test_runtime_report_records_native_core_geometry_rows_and_sites(tmp_path): - _write_candidate(tmp_path, "floorplan.core_util", 0.8) - config_path = _write_config(tmp_path, mode="die_util", field="utilization", value=0.8) - feature_path = tmp_path / "feature.json" - feature_path.write_text( - json.dumps( - { - "Design Layout": { - "core_area": 800.0, - "core_bounding_width": 40.0, - "core_bounding_height": 20.0, - } - } - ), - encoding="utf-8", - ) - report_path = tmp_path / "report.rpt" - report_path.write_text("Number - Site | 120\nNumber - Row | 30\n", encoding="utf-8") - - _write_floorplan_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - config_path, - feature_path=feature_path, - report_path=report_path, - ) - - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - observation = report["consumer_observation"] - assert observation["core_geometry"] == { - "width": {"value": 40.0, "unit": "um"}, - "height": {"value": 20.0, "unit": "um"}, - "area": {"value": 800.0, "unit": "um^2"}, - "aspect_ratio": {"value": 2.0, "unit": "ratio"}, - } - assert observation["rows"] == {"count": 30, "observed": True} - assert observation["sites"] == {"count": 120, "observed": True} diff --git a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py b/test/tools/ecc_dreamplace/test_parameter_runtime_report.py deleted file mode 100644 index 5654d91fc..000000000 --- a/test/tools/ecc_dreamplace/test_parameter_runtime_report.py +++ /dev/null @@ -1,394 +0,0 @@ -from __future__ import annotations - -import json -import sys -from types import SimpleNamespace - -from chipcompiler.tools.ecc_dreamplace.parameter_runtime_report import ( - _capture_native_runtime, - _observe_native_model, - _write_parameter_runtime_report, -) - - -class _Scalar: - def __init__(self, value): - self.value = value - - def item(self): - return self.value - - -def _engine(*, target_density=None, cell_padding_x=None): - data_collections = SimpleNamespace(target_density=_Scalar(target_density)) - return SimpleNamespace( - placer=SimpleNamespace(data_collections=data_collections), - placedb=SimpleNamespace(cell_padding_x=cell_padding_x, num_movable_nodes=12), - ) - - -def test_runtime_report_records_native_density_consumer(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), - encoding="utf-8", - ) - params = SimpleNamespace(target_density=0.85) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - params, - engine=_engine(target_density=0.85), - ppa={"iteration": 3}, - engine_succeeded=True, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "used" - assert report["activation"]["consumers"][0]["consumer_id"] == "dreamplace.density_objective" - assert report["consumer_observation"] == { - "density_tensor_value": 0.85, - "effective_target_density": 0.85, - "evidence_complete": True, - "placement_iteration_count": 3, - "requested_target_density": 0.85, - } - - -def test_runtime_report_records_density_utilization_floor_transition(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}]}), - encoding="utf-8", - ) - - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(target_density=0.8), - engine=_engine(target_density=0.8), - ppa={"iteration": 4}, - engine_succeeded=True, - ) - - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_initial"] == {"unit": "ratio", "value": 0.8} - assert report["transitions"] == [ - { - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": report["activation"]["consumers"][0]["evidence_sha256"], - "from": "materialized", - "reason": "DREAMPlace utilization lower bound", - "rule_id": "dreamplace.target_density.utilization_floor", - "sequence": 0, - "to": "overridden", - "value": 0.8, - } - ] - - -def test_runtime_report_does_not_parse_logged_parameter_values(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), - encoding="utf-8", - ) - log_dir = tmp_path / "place_dreamplace" / "log" - log_dir.mkdir(parents=True) - (log_dir / "place.log").write_text("parameters = {'target_density': 0.2}\n", encoding="utf-8") - - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(target_density=0.85), - engine=_engine(target_density=0.85), - ppa={"iteration": 2}, - engine_succeeded=True, - ) - - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_final"]["value"] == 0.85 - - -def test_runtime_report_uses_objective_weight_unit(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.density_weight", "value": 0.001}]}), - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(density_weight=0.001), - engine=SimpleNamespace( - native_runtime_probe={ - "density_weight_initializations": [0.004], - "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], - } - ), - ppa={"iteration": 5, "objective": 12.5}, - engine_succeeded=True, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_initial"]["unit"] == "objective_weight" - assert report["effective_final"]["unit"] == "objective_weight" - assert report["consumer_observation"] == { - "configured_density_weight": 0.001, - "density_weight_update_count": 1, - "density_weight_updates": [{"after": 0.006, "before": 0.004, "sequence": 0}], - "evidence_complete": True, - "final_objective": 12.5, - "final_internal_density_weight": 0.006, - "internal_initial_density_weight": 0.004, - "placement_iteration_count": 5, - } - - -def test_runtime_report_records_overflow_predicate_evaluation(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_overflow", "value": 0.1}]}), - encoding="utf-8", - ) - - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(stop_overflow=0.1), - engine=SimpleNamespace(metrics={"overflow": [0.7, _Scalar(0.12), 0.08]}), - ppa={"iteration": 7, "overflow": 0.08}, - engine_succeeded=True, - ) - - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "used" - assert report["activation"]["consumers"][0]["outcome"] == "evaluated" - assert report["consumer_observation"] == { - "comparison_count": 3, - "effective_stop_overflow": 0.1, - "evidence_complete": True, - "final_overflow": 0.08, - "minimum_observed_overflow": 0.08, - "placement_iteration_count": 7, - "threshold_reached": True, - } - - -def test_runtime_report_preserves_consumed_cell_padding_after_restore(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.cell_padding_x", "value": 400}]}), - encoding="utf-8", - ) - - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(cell_padding_x=0), - engine=_engine(target_density=0.8, cell_padding_x=200), - ppa={"iteration": 3}, - engine_succeeded=True, - ) - - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["effective_initial"] == {"unit": "dbu", "value": 200} - assert report["activation"]["status"] == "used" - assert report["consumer_observation"] == { - "effective_padding_dbu": 200, - "evidence_complete": True, - "movable_node_count": 12, - "placement_iteration_count": 3, - "requested_padding_dbu": 400, - } - - -def test_runtime_report_does_not_mark_disabled_routability_without_gate_evidence(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": False}]}), - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(routability_opt_flag=False), - engine=SimpleNamespace(), - ppa={"iteration": 3}, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "unknown" - assert report["consumer_observation"]["evidence_complete"] is False - - -def test_runtime_report_marks_disabled_routability_not_activated_with_gate_evidence(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": False}]}), - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(routability_opt_flag=False), - engine=SimpleNamespace(native_runtime_probe={"routability_branch_round_count": 0}), - ppa={"iteration": 3}, - engine_succeeded=True, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "not_activated" - assert report["application_status"] == "applied" - assert report["consumer_observation"]["evidence_complete"] is True - - -def test_runtime_report_requires_a_native_routability_round(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.routability_opt", "value": True}]}), - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(routability_opt_flag=True), - engine=SimpleNamespace(), - ppa={"iteration": 3}, - engine_succeeded=True, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "unknown" - assert report["consumer_observation"]["evidence_complete"] is False - - log_dir = tmp_path / "place_dreamplace" / "log" - log_dir.mkdir(parents=True) - (log_dir / "place.log").write_text( - "routability optimization round 0: adjust area flags = (1, 1, 0)\n", - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(routability_opt_flag=True), - engine=SimpleNamespace(native_runtime_probe={"routability_branch_round_count": 1}), - ppa={"iteration": 3}, - engine_succeeded=True, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"]["status"] == "used" - assert report["consumer_observation"]["branch_round_count"] == 1 - - -def test_runtime_report_binds_producer_revision_and_source(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), - encoding="utf-8", - ) - - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(target_density=0.85), - engine=_engine(target_density=0.85), - ppa={"iteration": 2}, - engine_succeeded=True, - ) - - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["tool"]["name"] == "DREAMPlace" - assert report["tool"]["revision"] == "ecc.dreamplace.parameter_runtime_report.v2" - assert report["tool"]["source_sha256"].startswith("sha256:") - assert len(report["tool"]["source_sha256"]) == 71 - - -def test_native_probe_observes_density_updates_and_routability_calls(): - probe = { - "density_weight_initializations": [], - "density_weight_updates": [], - "routability_branch_round_count": 0, - } - model = SimpleNamespace(density_weight=0.0) - - def initialize_density_weight(): - model.density_weight = 0.004 - return model.density_weight - - def update_density_weight(): - model.density_weight = 0.006 - return "updated" - - model.initialize_density_weight = initialize_density_weight - model.op_collections = SimpleNamespace( - update_density_weight_op=update_density_weight, - adjust_node_area_op=lambda: "adjusted", - ) - - _observe_native_model(model, probe) - - assert model.initialize_density_weight() == 0.004 - assert model.op_collections.update_density_weight_op() == "updated" - assert model.op_collections.adjust_node_area_op() == "adjusted" - assert probe == { - "density_weight_initializations": [0.004], - "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], - "routability_branch_round_count": 1, - } - - -def test_native_probe_skips_candidate_without_runtime_hooks(tmp_path, monkeypatch): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.85}]}), - encoding="utf-8", - ) - - class PlaceObj: - pass - - original_init = PlaceObj.__init__ - monkeypatch.setitem(sys.modules, "dreamplace.PlaceObj", SimpleNamespace(PlaceObj=PlaceObj)) - - with _capture_native_runtime(SimpleNamespace(directory=tmp_path)) as probe: - assert probe == {} - assert PlaceObj.__init__ is original_init - - -def test_native_probe_restores_runtime_hooks(tmp_path, monkeypatch): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.density_weight", "value": 0.001}]}), - encoding="utf-8", - ) - - class PlaceObj: - pass - - original_init = PlaceObj.__init__ - monkeypatch.setitem(sys.modules, "dreamplace.PlaceObj", SimpleNamespace(PlaceObj=PlaceObj)) - - with _capture_native_runtime(SimpleNamespace(directory=tmp_path)) as probe: - assert PlaceObj.__init__ is not original_init - assert probe == { - "density_weight_initializations": [], - "density_weight_updates": [], - "routability_branch_round_count": 0, - } - - assert PlaceObj.__init__ is original_init - - -def test_runtime_report_does_not_claim_use_before_engine_success(tmp_path): - analysis = tmp_path / "analysis" - analysis.mkdir() - (analysis / "candidate_materialization.v1.json").write_text( - json.dumps({"patch": [{"knob_id": "place.target_density", "value": 0.2}]}), - encoding="utf-8", - ) - _write_parameter_runtime_report( - SimpleNamespace(directory=tmp_path), - SimpleNamespace(target_density=0.8), - engine=_engine(target_density=0.8), - ppa={"iteration": 3}, - ) - report = json.loads((analysis / "parameter_runtime_report.v1.json").read_text()) - assert report["activation"] == {"status": "unknown", "consumers": []} - assert report["transitions"] == [] From 1c46c5536ccfecd1149a46e3c1830b5d9b6f254f Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 10:05:39 +0800 Subject: [PATCH 63/90] refactor: isolate agent runtime behavior --- agent/runtime_env.py | 58 +++++++++ agent/server.py | 2 + agent/test/test_engine.py | 45 ++++++- agent/test/test_requests.py | 12 ++ agent/test/test_runtime.py | 117 +++++++++++++++++++ agent/test/test_tools.py | 38 ++++++ agent/tools.py | 10 +- chipcompiler/runtime/workspace_api.py | 2 +- chipcompiler/tools/ecc/runner.py | 7 +- chipcompiler/tools/ecc_sizer/runner.py | 8 +- chipcompiler/tools/ecc_sizer/utility.py | 29 +---- test/engine/test_state_machine_regression.py | 22 ---- test/runtime/test_operations.py | 55 +-------- test/runtime/test_workspace_api.py | 6 +- test/tools/ecc/test_runner.py | 27 +---- test/tools/ecc_sizer/_sizer_helpers.py | 4 +- test/tools/ecc_sizer/test_module.py | 16 --- test/tools/ecc_sizer/test_runner.py | 18 +-- test/tools/ecc_sizer/test_runner_cleanup.py | 6 +- 19 files changed, 300 insertions(+), 182 deletions(-) create mode 100644 agent/runtime_env.py create mode 100644 agent/test/test_runtime.py diff --git a/agent/runtime_env.py b/agent/runtime_env.py new file mode 100644 index 000000000..7d7706bf9 --- /dev/null +++ b/agent/runtime_env.py @@ -0,0 +1,58 @@ +"""Process environment preparation for the opt-in Agent runtime.""" + +import os +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +_SIZER_EXECUTABLES = ( + Path("bin") / "Sizer", + Path("build") / "src" / "Sizer", + Path("build") / "Sizer", + Path("Sizer"), +) + + +def _packaged_sizer_executable() -> Path | None: + root_value = os.environ.get("CHIPCOMPILER_ECC_SIZER_ROOT", "").strip() + if not root_value: + return None + + root = Path(root_value).expanduser() + return next( + ( + candidate.resolve() + for relative in _SIZER_EXECUTABLES + if (candidate := root / relative).is_file() and os.access(candidate, os.X_OK) + ), + None, + ) + + +def prepare_agent_runtime_environment() -> None: + executable = _packaged_sizer_executable() + if executable is None: + return + + binary_dir = str(executable.parent) + path_entries = os.environ.get("PATH", "").split(os.pathsep) + if binary_dir not in path_entries: + os.environ["PATH"] = os.pathsep.join((binary_dir, *filter(None, path_entries))) + + +@contextmanager +def isolated_sizer_loader_environment() -> Iterator[None]: + if _packaged_sizer_executable() is None: + yield + return + + names = ("LD_LIBRARY_PATH", "LD_PRELOAD") + previous = {name: os.environ.pop(name, None) for name in names} + try: + yield + finally: + for name, value in previous.items(): + if value is not None: + os.environ[name] = value + else: + os.environ.pop(name, None) diff --git a/agent/server.py b/agent/server.py index 51a4732b8..3635c569f 100644 --- a/agent/server.py +++ b/agent/server.py @@ -6,6 +6,7 @@ from .methods import AGENT_RUNTIME_METHODS, agent_method_names from .requests import parse_agent_request_model +from .runtime_env import prepare_agent_runtime_environment from .workspace_api import FlowAgentRuntimeApi @@ -16,6 +17,7 @@ def __init__( *, persistent_db_enabled: bool = False, ): + prepare_agent_runtime_environment() super().__init__(api=api, persistent_db_enabled=persistent_db_enabled) self.agent_api = FlowAgentRuntimeApi(self.api) self._register_agent_methods() diff --git a/agent/test/test_engine.py b/agent/test/test_engine.py index 2e80a2dea..b44e67405 100644 --- a/agent/test/test_engine.py +++ b/agent/test/test_engine.py @@ -1,9 +1,10 @@ +import json from types import SimpleNamespace import pytest from agent.engine import AgentEngineFlow -from chipcompiler.data import EccStep, StateEnum, Workspace +from chipcompiler.data import EccOutput, EccStep, StateEnum, Workspace from chipcompiler.data.workspace import Flow @@ -38,3 +39,45 @@ def run_step(**_kwargs): assert flow.run_step(step) is expected_state assert flow.check_state("route", "ecc", expected_state) + + +def test_agent_incomplete_step_normalized_on_resume(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + persisted_steps = [ + { + "name": name, + "tool": "ecc", + "state": state, + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + for name, state in (("Synthesis", "Success"), ("Floorplan", "Incomplete")) + ] + workspace = Workspace( + directory=tmp_path, + flow=Flow(path=home / "flow.json"), + ) + flow = AgentEngineFlow(workspace) + workspace.flow.data = {"steps": persisted_steps} + flow.save() + flow.workspace_steps = [] + for name in ("Synthesis", "Floorplan"): + directory = tmp_path / f"{name}_ecc" + directory.mkdir() + flow.workspace_steps.append( + EccStep( + name=name, + tool="ecc", + directory=directory, + output=EccOutput(verilog=directory / "design.v"), + ) + ) + flow.engine_db = SimpleNamespace(engine=None) + monkeypatch.setattr("agent.engine.run_agent_step", lambda **_kwargs: True) + monkeypatch.setattr(flow, "check_step_result", lambda **_kwargs: True) + + assert flow.run_step(flow.workspace_steps[1], rerun=False) == StateEnum.Success + persisted = json.loads((home / "flow.json").read_text()) + assert persisted["steps"][1]["state"] == StateEnum.Success.value diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 5781dac38..f1ef3693d 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -31,6 +31,18 @@ def test_agent_runtime_server_registers_isolated_methods(): assert set(agent_method_names()).issubset(server.capabilities) +def test_agent_runtime_server_prepares_agent_environment(monkeypatch): + calls = [] + monkeypatch.setattr( + "agent.server.prepare_agent_runtime_environment", + lambda: calls.append(True), + ) + + AgentRuntimeServer() + + assert calls == [True] + + def test_agent_request_normalizes_camel_case_fields(): request = parse_agent_request_model( CandidateRerunRequest, diff --git a/agent/test/test_runtime.py b/agent/test/test_runtime.py new file mode 100644 index 000000000..d8d337364 --- /dev/null +++ b/agent/test/test_runtime.py @@ -0,0 +1,117 @@ +import os +import shutil +import threading + +import pytest + +from agent.runtime_env import prepare_agent_runtime_environment +from chipcompiler.runtime.operations import RuntimeOperationFailed, RuntimeOperationManager + + +@pytest.mark.parametrize( + "relative_executable", + ("bin/Sizer", "build/src/Sizer", "build/Sizer", "Sizer"), +) +def test_agent_runtime_prepares_packaged_sizer_environment( + tmp_path, + monkeypatch, + relative_executable, +): + runtime_root = tmp_path / "ecc-sizer" + executable = runtime_root / relative_executable + executable.parent.mkdir(parents=True, exist_ok=True) + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + monkeypatch.setenv("LD_LIBRARY_PATH", "/packaged/lib") + monkeypatch.setenv("LD_PRELOAD", "/packaged/preload.so") + + prepare_agent_runtime_environment() + + assert shutil.which("Sizer") == str(executable.resolve()) + assert os.environ["LD_LIBRARY_PATH"] == "/packaged/lib" + assert os.environ["LD_PRELOAD"] == "/packaged/preload.so" + + +def test_agent_runtime_without_packaged_sizer_preserves_environment(tmp_path, monkeypatch): + monkeypatch.delenv("CHIPCOMPILER_ECC_SIZER_ROOT", raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setenv("LD_LIBRARY_PATH", "/host/lib") + monkeypatch.setenv("LD_PRELOAD", "/host/preload.so") + + prepare_agent_runtime_environment() + + assert os.environ["PATH"] == str(tmp_path) + assert os.environ["LD_LIBRARY_PATH"] == "/host/lib" + assert os.environ["LD_PRELOAD"] == "/host/preload.so" + + +def test_structured_candidate_failure_preserves_partial_result() -> None: + events = [] + manager = RuntimeOperationManager(events.append) + partial = {"candidateRootRef": ".agent/candidates/candidate-1"} + + def runner(_observer): + raise RuntimeOperationFailed("candidate Harden failed", result=partial) + + started = manager.start( + workspace_id="workspace-1", + kind="candidate_rerun", + origin="agent", + rerun=True, + step="place", + idempotency_key="failed-candidate", + runner=runner, + ) + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "failed" + assert status["result"] == partial + assert _wait_for_event(events, "operation.failed")["payload"]["result"] == partial + + +def test_cancelled_candidate_operation_preserves_runner_result() -> None: + entered = threading.Event() + release = threading.Event() + manager = RuntimeOperationManager() + + def runner(_observer): + entered.set() + assert release.wait(timeout=1) + return {"candidateRootRef": ".agent/candidates/candidate-1"} + + started = manager.start( + workspace_id="workspace-1", + kind="candidate_rerun", + origin="agent", + rerun=True, + step="place", + idempotency_key="cancelled-candidate", + runner=runner, + ) + assert entered.wait(timeout=1) + assert manager.request_cancel(started["operationId"])["accepted"] is True + release.set() + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "cancelled" + assert status["result"] == {"candidateRootRef": ".agent/candidates/candidate-1"} + + +def _wait_for_event(events: list[dict], event_type: str) -> dict: + for _ in range(200): + for event in events: + if event["type"] == event_type: + return event + threading.Event().wait(0.01) + raise AssertionError(f"event not received: {event_type}") + + +def _wait_for_terminal(manager: RuntimeOperationManager, operation_id: str) -> dict: + for _ in range(100): + status = manager.operation_status(operation_id) + if status["state"] in {"succeeded", "failed", "cancelled"}: + return status + threading.Event().wait(0.01) + return manager.operation_status(operation_id) diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index f652db588..90477161f 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -1,4 +1,5 @@ import json +import os from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace @@ -56,6 +57,43 @@ def run_step(workspace, step, ecc_module): assert consumed == [0.65] +def test_agent_sizer_runner_isolates_loader_environment(monkeypatch, tmp_path): + workspace = SimpleNamespace( + directory=str(tmp_path), + config={}, + logger=SimpleNamespace(), + flow=SimpleNamespace(data={"steps": [{"name": "Timing optimization", "tool": "sizer"}]}), + ) + step = SimpleNamespace(name="Timing optimization", tool="sizer") + runtime_root = tmp_path / "sizer" + executable = runtime_root / "bin" / "Sizer" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o755) + observed = {} + + def run_step(**_kwargs): + observed.update( + LD_LIBRARY_PATH=os.environ.get("LD_LIBRARY_PATH"), + LD_PRELOAD=os.environ.get("LD_PRELOAD"), + ) + return True + + tool = SimpleNamespace(build_step_config=lambda *_args: None, run_step=run_step) + monkeypatch.setattr(eda, "load_eda_module", lambda *_args, **_kwargs: tool) + monkeypatch.setattr(eda, "log_workspace_step", lambda *_args, **_kwargs: None) + monkeypatch.setattr(eda, "reapply_materialized_candidate_config", lambda *_args: None) + monkeypatch.setattr(eda, "run_with_parameter_observation", lambda *_args: _args[-1]()) + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("LD_LIBRARY_PATH", "/packaged/lib") + monkeypatch.setenv("LD_PRELOAD", "/packaged/preload.so") + + assert eda.run_step(workspace, step) is True + assert observed == {"LD_LIBRARY_PATH": None, "LD_PRELOAD": None} + assert os.environ["LD_LIBRARY_PATH"] == "/packaged/lib" + assert os.environ["LD_PRELOAD"] == "/packaged/preload.so" + + def test_tool_runner_owns_candidate_runtime_report(monkeypatch, tmp_path): config_path = tmp_path / "config" / "dreamplace_ecc.json" _write_json(config_path, {"target_density": 0.8}) diff --git a/agent/tools.py b/agent/tools.py index 1cca11dd3..567d3eec2 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -3,6 +3,7 @@ from .data import reapply_materialized_candidate_config from .data.parameter_runtime_observer import run_with_parameter_observation +from .runtime_env import isolated_sizer_loader_environment def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: @@ -12,9 +13,16 @@ def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool eda_module.build_step_config(workspace, step) materialization = reapply_materialized_candidate_config(workspace, step.name) log_workspace_step(step, workspace.logger) + + def run_tool(): + if step.tool != "sizer": + return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) + with isolated_sizer_loader_environment(): + return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) + return run_with_parameter_observation( workspace, step, materialization, - lambda: eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module), + run_tool, ) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index afd02ccf9..ca72b9127 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -2067,7 +2067,7 @@ 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_harden_flow(): + for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): engine_flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 018d3c921..147820b35 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -561,13 +561,10 @@ def run_cts(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No if ecc_module is not None: sub_flow.update_step(step_name=EccSubFlowEnum.load_data.value, state=StateEnum.Success) - config_path = workspace.config.get(f"{StepEnum.CTS.value}", "") - engine_succeeded = ecc_module.run_cts( - config=config_path, + ecc_module.run_cts( + config=workspace.config.get(f"{StepEnum.CTS.value}", ""), output=(step.data.steps or {}).get(StepEnum.CTS.value, ""), ) - if not engine_succeeded: - return False ecc_module.report_cts(output=(step.data.steps or {}).get(StepEnum.CTS.value, "")) diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index 7097bbb2d..e495fc1ac 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -11,12 +11,7 @@ from .builder import sizer_staging_def, sizer_staging_verilog from .subflow import SizerSubFlow, SizerSubFlowEnum -from .utility import ( - get_sizer_command, - get_sizer_subprocess_env, - is_eda_exist, - is_sizer_runtime_exist, -) +from .utility import get_sizer_command, is_eda_exist, is_sizer_runtime_exist logger = logging.getLogger(__name__) @@ -128,7 +123,6 @@ def run_step( stdout=None, stderr=subprocess.STDOUT, check=False, - env=get_sizer_subprocess_env(), ) if result.returncode != 0 or not _has_staging_outputs(step): diff --git a/chipcompiler/tools/ecc_sizer/utility.py b/chipcompiler/tools/ecc_sizer/utility.py index e8b3a46a9..b0a1b9d9b 100644 --- a/chipcompiler/tools/ecc_sizer/utility.py +++ b/chipcompiler/tools/ecc_sizer/utility.py @@ -5,13 +5,6 @@ _SIZER_RUNTIME_SENTINEL = Path("src") / "sizer_os.tcl" -def get_sizer_subprocess_env() -> dict[str, str]: - env = os.environ.copy() - env.pop("LD_LIBRARY_PATH", None) - env.pop("LD_PRELOAD", None) - return env - - def _is_sizer_root(path: Path) -> bool: return (path / _SIZER_RUNTIME_SENTINEL).is_file() @@ -53,28 +46,8 @@ def get_sizer_root() -> Path | None: def get_sizer_command() -> list[str]: - candidates: list[Path] = [] - override = os.environ.get("CHIPCOMPILER_ECC_SIZER_ROOT", "").strip() - if override: - root = Path(override).expanduser() - candidates.extend( - root / relative - for relative in ( - Path("bin") / "Sizer", - Path("build") / "src" / "Sizer", - Path("build") / "Sizer", - Path("Sizer"), - ) - ) - sizer = shutil.which("Sizer") - if sizer: - candidates.append(Path(sizer)) - - for candidate in candidates: - if candidate.is_file() and os.access(candidate, os.X_OK): - return [str(candidate.resolve())] - return [] + return [str(Path(sizer).resolve())] if sizer else [] def is_eda_exist() -> bool: diff --git a/test/engine/test_state_machine_regression.py b/test/engine/test_state_machine_regression.py index 1e13d025d..01b78ab76 100644 --- a/test/engine/test_state_machine_regression.py +++ b/test/engine/test_state_machine_regression.py @@ -450,28 +450,6 @@ def test_normalization_emits_warning(self, tmp_path, monkeypatch, caplog): assert "Normalizing legacy" in caplog.text assert "Incomplete" in caplog.text - def test_agent_incomplete_step_normalized_on_resume(self, tmp_path, monkeypatch): - """AgentEngineFlow: legacy Incomplete step resumes without ValueError.""" - import agent.engine as agent_engine - - flow = _make_resume_workspace( - tmp_path, - [("Synthesis", "Success"), ("Floorplan", "Incomplete")], - ) - agent_flow = agent_engine.AgentEngineFlow.__new__(agent_engine.AgentEngineFlow) - agent_flow.workspace = flow.workspace - agent_flow.workspace_steps = flow.workspace_steps - agent_flow.engine_db = flow.engine_db - - monkeypatch.setattr(agent_engine, "run_agent_step", lambda **_kw: True) - monkeypatch.setattr(agent_flow, "check_step_result", lambda **_kw: True) - - result = agent_flow.run_step(agent_flow.workspace_steps[1], rerun=False) - assert result == StateEnum.Success - - persisted = json.loads((tmp_path / "home" / "flow.json").read_text()) - assert persisted["steps"][1]["state"] == StateEnum.Success.value - class TestRunStepsLedgerCompleteness: """run_steps verifies full-ledger coverage by default; callers binding diff --git a/test/runtime/test_operations.py b/test/runtime/test_operations.py index f8b95c76d..70c9e77e6 100644 --- a/test/runtime/test_operations.py +++ b/test/runtime/test_operations.py @@ -3,60 +3,7 @@ from chipcompiler.data import StateEnum from chipcompiler.runtime import operations -from chipcompiler.runtime.operations import RuntimeOperationFailed, RuntimeOperationManager - - -def test_structured_failure_preserves_partial_result() -> None: - events = [] - manager = RuntimeOperationManager(events.append) - partial = {"candidateRootRef": ".agent/candidates/candidate-1"} - - def runner(_observer): - raise RuntimeOperationFailed("candidate Harden failed", result=partial) - - started = manager.start( - workspace_id="workspace-1", - kind="candidate_rerun", - origin="agent", - rerun=True, - step="place", - idempotency_key="failed-candidate", - runner=runner, - ) - - status = _wait_for_terminal(manager, started["operationId"]) - assert status["state"] == "failed" - assert status["result"] == partial - failed = _wait_for_event(events, "operation.failed") - assert failed["payload"]["result"] == partial - - -def test_cancelled_operation_preserves_runner_result() -> None: - entered = threading.Event() - release = threading.Event() - manager = RuntimeOperationManager() - - def runner(_observer): - entered.set() - assert release.wait(timeout=1) - return {"candidateRootRef": ".agent/candidates/candidate-1"} - - started = manager.start( - workspace_id="workspace-1", - kind="candidate_rerun", - origin="agent", - rerun=True, - step="place", - idempotency_key="cancelled-candidate", - runner=runner, - ) - assert entered.wait(timeout=1) - assert manager.request_cancel(started["operationId"])["accepted"] is True - release.set() - - status = _wait_for_terminal(manager, started["operationId"]) - assert status["state"] == "cancelled" - assert status["result"] == {"candidateRootRef": ".agent/candidates/candidate-1"} +from chipcompiler.runtime.operations import RuntimeOperationManager def test_successful_step_waits_for_matching_render_ack_before_completing(): diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index d93b5a1ed..a108cb1b0 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -206,7 +206,7 @@ def fake_load_workspace(directory): ) monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( - "chipcompiler.rtl2gds.build_harden_flow", + "chipcompiler.rtl2gds.build_rtl2gds_flow", lambda: [("Synthesis", "yosys", "Unstart")], ) @@ -219,7 +219,7 @@ def fake_load_workspace(directory): return capture, ws -def test_runtime_workspace_defaults_to_harden_flow(monkeypatch): +def test_runtime_workspace_defaults_to_rtl2gds_flow(monkeypatch): from chipcompiler.runtime.workspace_api import build_flow_for_workspace workspace = SimpleNamespace(flow=SimpleNamespace(data={})) @@ -235,7 +235,7 @@ def test_runtime_workspace_defaults_to_harden_flow(monkeypatch): flow = build_flow_for_workspace(workspace) - assert flow.added_steps == [("Harden", "ecc", "Unstart")] + assert flow.added_steps == [("rtl2gds", "ecc", "Unstart")] def _assert_call_waits_for_session_lock(api, workspace_id, call, entered): diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 688d309a8..4634d60e9 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -102,14 +102,12 @@ def update_step(self, **kwargs): class FakeCtsModule: - def __init__(self, timing_quality, *, succeeded=True): + def __init__(self, timing_quality): self.calls = [] self.timing_quality = timing_quality - self.succeeded = succeeded def run_cts(self, **kwargs): self.calls.append(("run_cts", kwargs)) - return self.succeeded def update_step_paths(self, **kwargs): self.calls.append(("update_step_paths", kwargs)) @@ -436,29 +434,6 @@ def test_run_cts_merges_structured_timing_into_step_feature(tmp_path, monkeypatc ] -def test_run_cts_stops_when_native_flow_fails(tmp_path, monkeypatch): - config = tmp_path / "config" / "cts.json" - config.parent.mkdir() - config.write_text('{"max_fanout": 48}', encoding="utf-8") - workspace = Workspace( - directory=tmp_path, - design=OriginDesign(name="gcd", top_module="gcd"), - config={StepEnum.CTS.value: config}, - ) - step = build_step( - workspace=workspace, - step_name=StepEnum.CTS.value, - input_def=tmp_path / "input.def", - input_verilog=tmp_path / "input.v", - ) - build_step_space(step) - module = FakeCtsModule({}, succeeded=False) - monkeypatch.setattr(ecc_runner, "EccSubFlow", FakeSubFlow) - - assert ecc_runner.run_cts(workspace, step, module) is False - assert [call[0] for call in module.calls] == ["update_step_paths", "run_cts"] - - def test_run_sta_without_spef_reads_netlist_and_writes_to_step_report_and_feature( tmp_path, monkeypatch ): diff --git a/test/tools/ecc_sizer/_sizer_helpers.py b/test/tools/ecc_sizer/_sizer_helpers.py index 04a99631b..49bb2979d 100644 --- a/test/tools/ecc_sizer/_sizer_helpers.py +++ b/test/tools/ecc_sizer/_sizer_helpers.py @@ -72,8 +72,8 @@ def _write_staging(step: EccStep) -> None: def _fake_sizer_run(step: EccStep): - def fake_run(command, cwd, stdout, stderr, check, env): - del command, cwd, stdout, stderr, check, env + def fake_run(command, cwd, stdout, stderr, check): + del command, cwd, stdout, stderr, check _write_staging(step) return SimpleNamespace(returncode=0) diff --git a/test/tools/ecc_sizer/test_module.py b/test/tools/ecc_sizer/test_module.py index 49e06884a..7fdc3b4cb 100644 --- a/test/tools/ecc_sizer/test_module.py +++ b/test/tools/ecc_sizer/test_module.py @@ -379,22 +379,6 @@ def test_sizer_command_resolves_from_path_only(tmp_path, monkeypatch): assert is_eda_exist() is False -def test_sizer_command_resolves_from_runtime_root_without_path(tmp_path, monkeypatch): - from chipcompiler.tools.ecc_sizer.utility import get_sizer_command, is_eda_exist - - runtime_root = _sizer_runtime(tmp_path) - sizer = runtime_root / "bin" / "Sizer" - sizer.parent.mkdir(parents=True) - sizer.write_text("#!/bin/sh\n", encoding="utf-8") - sizer.chmod(0o755) - - monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) - monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) - - assert get_sizer_command() == [str(sizer.resolve())] - assert is_eda_exist() is True - - def test_sizer_runtime_root_resolves_from_path_binary(tmp_path, monkeypatch): from chipcompiler.tools.ecc_sizer.utility import find_sizer_root, get_sizer_root diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 6a37fe13a..4981bb79b 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -31,8 +31,8 @@ def test_sizer_runner_invokes_generated_command_and_checks_outputs(tmp_path, mon calls = [] - def fake_run(command, cwd, stdout, stderr, check, env): - calls.append((command, cwd, stdout, stderr, check, env)) + def fake_run(command, cwd, stdout, stderr, check): + calls.append((command, cwd, stdout, stderr, check)) _write_staging(step) return SimpleNamespace(returncode=0) @@ -42,9 +42,6 @@ def fake_run(command, cwd, stdout, stderr, check, env): monkeypatch.setattr(sizer_runner, "is_sizer_runtime_exist", lambda: True) monkeypatch.setattr(sizer_runner, "is_dreamplace_exist", lambda: True) monkeypatch.setattr(subprocess, "run", fake_run) - monkeypatch.setenv("LD_LIBRARY_PATH", "/app/resources/binaries/_internal") - monkeypatch.setenv("LD_PRELOAD", "/bad/preload.so") - monkeypatch.setenv("KEEP_ME", "kept") assert ( sizer_runner.run_step( @@ -75,11 +72,6 @@ def fake_run(command, cwd, stdout, stderr, check, env): None, subprocess.STDOUT, False, - { - key: value - for key, value in os.environ.items() - if key not in {"LD_LIBRARY_PATH", "LD_PRELOAD"} - }, ) ] @@ -167,7 +159,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), ) assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete @@ -193,8 +185,8 @@ def test_sizer_runner_inherits_captured_stdio_instead_of_truncating_step_log( Path(step.log.file).write_text("preface\n", encoding="utf-8") seen = {} - def fake_run(command, cwd, stdout, stderr, check, env): - del command, cwd, check, env + def fake_run(command, cwd, stdout, stderr, check): + del command, cwd, check seen["stdout"] = stdout seen["stderr"] = stderr return SimpleNamespace(returncode=1) diff --git a/test/tools/ecc_sizer/test_runner_cleanup.py b/test/tools/ecc_sizer/test_runner_cleanup.py index 872ce4e8e..6308ba0ac 100644 --- a/test/tools/ecc_sizer/test_runner_cleanup.py +++ b/test/tools/ecc_sizer/test_runner_cleanup.py @@ -206,7 +206,7 @@ def record_legalize(*args, **kwargs): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), ) with pytest.raises(OSError, match="cannot unlink staging"): @@ -242,7 +242,7 @@ def test_sizer_rerun_resets_previous_subflow_success(tmp_path, monkeypatch): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), ) assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete states = _subflow_states(step) @@ -283,7 +283,7 @@ def fake_legalize(*args, **kwargs): monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check, env: SimpleNamespace(returncode=0), + lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), ) monkeypatch.setattr(sizer_runner, "legalize_layout", fake_legalize) From 4384f35dd935c311e90bec1871f4764de0c78edd Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 10:13:44 +0800 Subject: [PATCH 64/90] fix: preserve candidate result when evidence is missing --- agent/test/test_workspace_api.py | 51 ++++++++++++++++++++++++++++++++ agent/workspace_api.py | 2 -- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index b50f53839..859769b54 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -10,6 +10,7 @@ from agent.requests import CandidateRerunRequest from agent.workspace_api import ( FlowAgentRuntimeApi, + _candidate_rerun_result, _candidate_rerun_steps, _candidate_step_artifact_dirs, _materialize_candidate_rerun, @@ -523,6 +524,56 @@ def run_candidate_step(_flow, step, **_kwargs): ) +def test_succeeded_candidate_preserves_flow_when_runtime_report_is_missing( + monkeypatch, tmp_path +) -> None: + analysis = tmp_path / "analysis" + analysis.mkdir() + (analysis / "candidate_materialization.v1.json").write_text("{}", encoding="utf-8") + (tmp_path / "home").mkdir() + (tmp_path / "home" / "flow.json").write_text('{"steps": []}', encoding="utf-8") + for suffix in ("gds", "lef", "lib"): + output = tmp_path / "Harden_ecc" / "output" + output.mkdir(parents=True, exist_ok=True) + (output / f"gcd_Harden.{suffix}").write_text("artifact", encoding="utf-8") + workspace = SimpleNamespace(directory=tmp_path, design=SimpleNamespace(name="gcd")) + parent = { + "root_ref": None, + "manifest_ref": None, + "manifest_sha256": None, + "flow_sha256": "sha256:" + "1" * 64, + "state_sha256": "sha256:" + "2" * 64, + } + missing_report = RuntimeApiError("command_failed", "candidate runtime report is unavailable") + monkeypatch.setattr( + "agent.workspace_api.reapply_materialized_candidate_config", lambda *_args: None + ) + monkeypatch.setattr( + "agent.workspace_api._candidate_parameter_receipt", + lambda *_args: (_ for _ in ()).throw(missing_report), + ) + + result = _candidate_rerun_result( + workspace, + SimpleNamespace( + candidate_id="candidate-1", + target_step="place", + end_step="Harden", + execution_scope="full_flow", + ), + ".agent/candidates/candidate-1", + parent, + terminal_state="succeeded", + ) + + assert result["evidenceError"] == "candidate runtime report is unavailable" + assert "parameterApplicationReceipt" not in result + manifest = json.loads( + (tmp_path / "analysis" / "candidate_workspace.v1.json").read_text(encoding="utf-8") + ) + assert manifest["terminal_state"] == "succeeded" + + def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, tmp_path): analysis = tmp_path / "analysis" analysis.mkdir() diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 2e2d20347..bb8b37297 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -607,8 +607,6 @@ def _candidate_rerun_result( parent, ) except Exception as exc: - if terminal_state == "succeeded": - raise evidence_error = str(exc) result = { "candidateId": request.candidate_id, From 0ad60b3f75c8e5f7d6e9d6657ab704643c117f88 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 10:53:23 +0800 Subject: [PATCH 65/90] refactor: isolate agent RPC entrypoint --- .../build-pyinstaller-bundle/action.yml | 4 ++ agent/rpc_server.py | 18 +++++++++ agent/test/test_requests.py | 14 ++----- chipcompiler/cli/commands/rpc.py | 6 +-- chipcompiler/runtime/stdio_server.py | 8 +--- ecc.spec | 40 +++++++++++++++++++ pyproject.toml | 3 +- test/cli/test_rpc_cli.py | 1 + test/packaging/test_cli_entrypoint.py | 1 - 9 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 agent/rpc_server.py diff --git a/.github/actions/build-pyinstaller-bundle/action.yml b/.github/actions/build-pyinstaller-bundle/action.yml index e2b873ca9..624fadc13 100644 --- a/.github/actions/build-pyinstaller-bundle/action.yml +++ b/.github/actions/build-pyinstaller-bundle/action.yml @@ -76,4 +76,8 @@ runs: "$SMOKE_DIR/ecc" --help "$SMOKE_DIR/ecc" --version "$SMOKE_DIR/ecc" version --json + test -x "$SMOKE_DIR/ecc-agent-rpc" + payload='{"jsonrpc":"2.0","method":"rpc.hello","id":1,"params":{"version":1}}' + response="$(printf 'Content-Length: %s\r\n\r\n%s' "${#payload}" "$payload" | "$SMOKE_DIR/ecc-agent-rpc")" + grep -q 'candidate.rerun' <<<"$response" test -x "$SMOKE_DIR/_internal/torch/bin/torch_shm_manager" diff --git a/agent/rpc_server.py b/agent/rpc_server.py new file mode 100644 index 000000000..7447baa0c --- /dev/null +++ b/agent/rpc_server.py @@ -0,0 +1,18 @@ +import multiprocessing +import sys + +from agent.server import AgentRuntimeServer +from chipcompiler.runtime.stdio_server import run_stdio_server + + +def main() -> int: + multiprocessing.freeze_support() + return run_stdio_server( + sys.stdin.buffer, + sys.stdout.buffer, + server=AgentRuntimeServer(), + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index f1ef3693d..bf3788859 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -201,24 +201,19 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): } -def test_agent_rpc_cli_is_explicitly_opt_in(): +def test_agent_rpc_uses_dedicated_entrypoint(): def request(method: str, request_id: int, params: dict | None = None) -> bytes: payload = {"jsonrpc": "2.0", "method": method, "id": request_id} if params is not None: payload["params"] = params return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) - def capabilities(*, agent_enabled: bool) -> list[str]: + def capabilities() -> list[str]: command = [ sys.executable, "-m", - "chipcompiler.cli.main", - "rpc", - "serve", - "--stdio", + "agent.rpc_server", ] - if agent_enabled: - command.append("--agent") completed = subprocess.run( command, input=request("rpc.hello", 1, {"version": 1}) + request("rpc.shutdown", 2), @@ -230,5 +225,4 @@ def capabilities(*, agent_enabled: bool) -> list[str]: assert completed.returncode == 0, completed.stderr.decode("utf-8", errors="replace") return responses[0]["result"]["capabilities"] - assert "candidate.rerun" not in capabilities(agent_enabled=False) - assert "candidate.rerun" in capabilities(agent_enabled=True) + assert "candidate.rerun" in capabilities() diff --git a/chipcompiler/cli/commands/rpc.py b/chipcompiler/cli/commands/rpc.py index 16b618a4e..48b46556f 100644 --- a/chipcompiler/cli/commands/rpc.py +++ b/chipcompiler/cli/commands/rpc.py @@ -24,14 +24,10 @@ def serve_cmd( help="Enable explicit persistent DB lifecycle RPC methods.", ), ] = False, - agent: Annotated[ - bool, - typer.Option("--agent", help="Enable the fixed Flow Agent RPC methods."), - ] = False, ) -> None: if not stdio: raise typer.BadParameter("--stdio is required", param_hint="--stdio") from chipcompiler.runtime.stdio_server import main - raise typer.Exit(code=main(persistent_db_enabled=persistent_db, agent_enabled=agent)) + raise typer.Exit(code=main(persistent_db_enabled=persistent_db)) diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index df5904cd6..e46e0b6b8 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -123,15 +123,9 @@ def _read_chunk(input_stream: BinaryIO) -> bytes: return input_stream.read(8192) -def main(*, persistent_db_enabled: bool = False, agent_enabled: bool = False) -> int: - server = RuntimeServer(persistent_db_enabled=persistent_db_enabled) - if agent_enabled: - from agent.server import AgentRuntimeServer - - server = AgentRuntimeServer(persistent_db_enabled=persistent_db_enabled) +def main(*, persistent_db_enabled: bool = False) -> int: return run_stdio_server( sys.stdin.buffer, sys.stdout.buffer, - server=server, persistent_db_enabled=persistent_db_enabled, ) diff --git a/ecc.spec b/ecc.spec index 2f530cb44..6ed62ca33 100644 --- a/ecc.spec +++ b/ecc.spec @@ -254,7 +254,19 @@ a = Analysis( noarchive=False, ) +agent_a = Analysis( + [str(ECC_DIR / "agent" / "rpc_server.py")], + pathex=[str(ECC_DIR)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[str(HOOKS_DIR)], + excludes=EXCLUDES, + noarchive=False, +) + pyz = PYZ(a.pure, a.zipped_data) +agent_pyz = PYZ(agent_a.pure, agent_a.zipped_data) if BUNDLE_MODE == "onedir": exe = EXE( @@ -268,12 +280,27 @@ if BUNDLE_MODE == "onedir": console=True, codesign_identity=CODESIGN_IDENTITY, ) + agent_exe = EXE( + agent_pyz, + agent_a.scripts, + [], + exclude_binaries=True, + name="ecc-agent-rpc", + strip=False, + upx=False, + console=True, + codesign_identity=CODESIGN_IDENTITY, + ) coll = COLLECT( exe, + agent_exe, a.binaries, + agent_a.binaries, a.zipfiles, + agent_a.zipfiles, a.datas, + agent_a.datas, strip=False, upx=False, name="ecc", @@ -292,3 +319,16 @@ else: console=True, codesign_identity=CODESIGN_IDENTITY, ) + agent_exe = EXE( + agent_pyz, + agent_a.scripts, + agent_a.binaries, + agent_a.zipfiles, + agent_a.datas, + [], + name="ecc-agent-rpc", + strip=False, + upx=True, + console=True, + codesign_identity=CODESIGN_IDENTITY, + ) diff --git a/pyproject.toml b/pyproject.toml index 010e37e8e..adb779ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "uvicorn>=0.27", ] scripts.ecc = "chipcompiler.cli.main:main" +scripts.ecc-agent-rpc = "agent.rpc_server:main" [dependency-groups] dev = [ @@ -66,7 +67,7 @@ constraint-dependencies = [ "llvmlite>=0.40.0", "numba>=0.57.0", ] -build-backend.module-name = [ "chipcompiler" ] +build-backend.module-name = [ "agent", "chipcompiler" ] build-backend.module-root = "" build-backend.source-exclude = [ "/scripts/**", diff --git a/test/cli/test_rpc_cli.py b/test/cli/test_rpc_cli.py index 0a3781eb4..e57cc5244 100644 --- a/test/cli/test_rpc_cli.py +++ b/test/cli/test_rpc_cli.py @@ -16,6 +16,7 @@ def test_rpc_serve_help_returns_zero_and_lists_stdio(capsys): assert rc == 0 assert "--stdio" in out assert "--persistent-db" in out + assert "--agent" not in out def test_rpc_serve_requires_stdio(capsys): diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index 6da80f699..acedee6f9 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -10,7 +10,6 @@ def test_ecc_console_script_in_pyproject(self): with open(pyproject, "rb") as f: data = tomllib.load(f) assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main" - assert set(data["project"]["scripts"]) == {"ecc"} def test_pyinstaller_spec_collects_jsonrpcserver_data_files(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) From 020af49a884a8404c126de9d6913f3cbbe29fd01 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 12:22:13 +0800 Subject: [PATCH 66/90] perf: share packaged ECC executable --- .../build-pyinstaller-bundle/action.yml | 1 + ecc.spec | 46 +++---------------- packaging/run_ecc.py | 13 ++++-- test/packaging/test_cli_entrypoint.py | 10 ++++ test/packaging/test_run_ecc.py | 35 ++++++++++++++ 5 files changed, 62 insertions(+), 43 deletions(-) create mode 100644 test/packaging/test_run_ecc.py diff --git a/.github/actions/build-pyinstaller-bundle/action.yml b/.github/actions/build-pyinstaller-bundle/action.yml index 624fadc13..c2a4e38fd 100644 --- a/.github/actions/build-pyinstaller-bundle/action.yml +++ b/.github/actions/build-pyinstaller-bundle/action.yml @@ -77,6 +77,7 @@ runs: "$SMOKE_DIR/ecc" --version "$SMOKE_DIR/ecc" version --json test -x "$SMOKE_DIR/ecc-agent-rpc" + test "$SMOKE_DIR/ecc" -ef "$SMOKE_DIR/ecc-agent-rpc" payload='{"jsonrpc":"2.0","method":"rpc.hello","id":1,"params":{"version":1}}' response="$(printf 'Content-Length: %s\r\n\r\n%s' "${#payload}" "$payload" | "$SMOKE_DIR/ecc-agent-rpc")" grep -q 'candidate.rerun' <<<"$response" diff --git a/ecc.spec b/ecc.spec index 6ed62ca33..543d35987 100644 --- a/ecc.spec +++ b/ecc.spec @@ -254,19 +254,7 @@ a = Analysis( noarchive=False, ) -agent_a = Analysis( - [str(ECC_DIR / "agent" / "rpc_server.py")], - pathex=[str(ECC_DIR)], - binaries=binaries, - datas=datas, - hiddenimports=hiddenimports, - hookspath=[str(HOOKS_DIR)], - excludes=EXCLUDES, - noarchive=False, -) - pyz = PYZ(a.pure, a.zipped_data) -agent_pyz = PYZ(agent_a.pure, agent_a.zipped_data) if BUNDLE_MODE == "onedir": exe = EXE( @@ -280,31 +268,17 @@ if BUNDLE_MODE == "onedir": console=True, codesign_identity=CODESIGN_IDENTITY, ) - agent_exe = EXE( - agent_pyz, - agent_a.scripts, - [], - exclude_binaries=True, - name="ecc-agent-rpc", - strip=False, - upx=False, - console=True, - codesign_identity=CODESIGN_IDENTITY, - ) coll = COLLECT( exe, - agent_exe, a.binaries, - agent_a.binaries, a.zipfiles, - agent_a.zipfiles, a.datas, - agent_a.datas, strip=False, upx=False, name="ecc", ) + ecc_exe_path = Path(coll.name) / "ecc" else: exe = EXE( pyz, @@ -319,16 +293,8 @@ else: console=True, codesign_identity=CODESIGN_IDENTITY, ) - agent_exe = EXE( - agent_pyz, - agent_a.scripts, - agent_a.binaries, - agent_a.zipfiles, - agent_a.datas, - [], - name="ecc-agent-rpc", - strip=False, - upx=True, - console=True, - codesign_identity=CODESIGN_IDENTITY, - ) + ecc_exe_path = Path(exe.name) + +agent_exe_path = ecc_exe_path.with_name(f"ecc-agent-rpc{ecc_exe_path.suffix}") +agent_exe_path.unlink(missing_ok=True) +os.link(ecc_exe_path, agent_exe_path) diff --git a/packaging/run_ecc.py b/packaging/run_ecc.py index 9f1e000ca..601305735 100644 --- a/packaging/run_ecc.py +++ b/packaging/run_ecc.py @@ -1,8 +1,7 @@ import multiprocessing import os import sys - -from chipcompiler.cli.main import main +from pathlib import Path def _configure_pyinstaller_runtime() -> None: @@ -11,7 +10,15 @@ def _configure_pyinstaller_runtime() -> None: os.environ.setdefault("ECC_PYINSTALLER_ROOT", bundle_root) +def main() -> int | None: + if Path(sys.argv[0]).stem == "ecc-agent-rpc": + from agent.rpc_server import main as entrypoint + else: + from chipcompiler.cli.main import main as entrypoint + return entrypoint() + + if __name__ == "__main__": multiprocessing.freeze_support() _configure_pyinstaller_runtime() - main() + raise SystemExit(main()) diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index acedee6f9..957e019a0 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -33,3 +33,13 @@ def test_pyinstaller_spec_filters_payloads_before_analysis(self): assert datas_filter_index < analysis_index assert binaries_filter_index < analysis_index + + def test_pyinstaller_spec_reuses_ecc_executable_for_agent_rpc(self): + project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + spec_path = os.path.join(project_root, "ecc.spec") + + with open(spec_path, encoding="utf-8") as f: + source = f.read() + + assert source.count(" = Analysis(") == 1 + assert "os.link(ecc_exe_path, agent_exe_path)" in source diff --git a/test/packaging/test_run_ecc.py b/test/packaging/test_run_ecc.py new file mode 100644 index 000000000..80c8213e6 --- /dev/null +++ b/test/packaging/test_run_ecc.py @@ -0,0 +1,35 @@ +import importlib.util +import os +import sys +from pathlib import Path + +import agent.rpc_server + + +def _load_entrypoint_module(): + project_root = Path(__file__).parents[2] + module_path = project_root / "packaging" / "run_ecc.py" + spec = importlib.util.spec_from_file_location("ecc_packaged_entrypoint", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_agent_rpc_alias_selects_agent_entrypoint(monkeypatch): + module = _load_entrypoint_module() + calls = [] + + monkeypatch.setattr(sys, "argv", [os.path.join("dist", "ecc-agent-rpc")]) + monkeypatch.setattr(agent.rpc_server, "main", lambda: calls.append("agent") or 7) + + assert module.main() == 7 + + assert calls == ["agent"] + + +def test_packaged_entrypoint_propagates_exit_code(): + project_root = Path(__file__).parents[2] + source = (project_root / "packaging" / "run_ecc.py").read_text() + + assert "raise SystemExit(main())" in source From 3adb5d02d9005887b2aaed838025944e255db6ee Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 15:39:28 +0800 Subject: [PATCH 67/90] fix: route agent full flows through agent engine --- agent/server.py | 7 +++++-- agent/test/test_requests.py | 16 ++++++++++++++++ agent/workspace_api.py | 8 ++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/agent/server.py b/agent/server.py index 3635c569f..c2a65b1f6 100644 --- a/agent/server.py +++ b/agent/server.py @@ -7,7 +7,7 @@ from .methods import AGENT_RUNTIME_METHODS, agent_method_names from .requests import parse_agent_request_model from .runtime_env import prepare_agent_runtime_environment -from .workspace_api import FlowAgentRuntimeApi +from .workspace_api import AgentWorkspaceRuntimeApi, FlowAgentRuntimeApi class AgentRuntimeServer(RuntimeServer): @@ -18,7 +18,10 @@ def __init__( persistent_db_enabled: bool = False, ): prepare_agent_runtime_environment() - super().__init__(api=api, persistent_db_enabled=persistent_db_enabled) + super().__init__( + api=api or AgentWorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled), + persistent_db_enabled=persistent_db_enabled, + ) self.agent_api = FlowAgentRuntimeApi(self.api) self._register_agent_methods() diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index bf3788859..31ab156e5 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -1,6 +1,7 @@ import json import subprocess import sys +from types import SimpleNamespace import pytest @@ -43,6 +44,21 @@ def test_agent_runtime_server_prepares_agent_environment(monkeypatch): assert calls == [True] +def test_agent_runtime_server_builds_full_flows_with_agent_engine(monkeypatch): + flow = SimpleNamespace(engine_db=None) + monkeypatch.setattr( + "agent.workspace_api.build_agent_flow_for_workspace", + lambda _workspace: flow, + ) + server = AgentRuntimeServer() + session = SimpleNamespace(workspace=SimpleNamespace(), db_handle=object()) + + result = server.api._build_flow_for_session(session, attach_session_db=True) + + assert result is flow + assert result.engine_db is session.db_handle + + def test_agent_request_normalizes_camel_case_fields(): request = parse_agent_request_model( CandidateRerunRequest, diff --git a/agent/workspace_api.py b/agent/workspace_api.py index bb8b37297..132fa239b 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -70,6 +70,14 @@ def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = return flow +class AgentWorkspaceRuntimeApi(WorkspaceRuntimeApi): + def _build_flow_for_session(self, session, *, attach_session_db: bool): + flow = build_agent_flow_for_workspace(session.workspace) + if attach_session_db: + flow.engine_db = session.db_handle + return flow + + class FlowAgentRuntimeApi: """Optional Flow Agent RPC handlers over one ECC workspace runtime.""" From a2c67905ff5a1012183262ca432161a8b7d20c23 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sat, 5 Sep 2026 18:35:57 +0800 Subject: [PATCH 68/90] perf: reduce agent candidate rerun overhead --- agent/candidate_clone.py | 36 +++++++++++++ agent/data/observed_callable.py | 14 +++++ agent/data/parameter_runtime_observer.py | 4 +- agent/engine.py | 4 +- agent/plot.py | 14 +++++ agent/test/test_engine.py | 22 ++++++++ agent/test/test_parameter_runtime_observer.py | 32 +++++++++++ agent/test/test_tools.py | 16 ++++++ agent/test/test_workspace_api.py | 53 +++++++++++++++++++ agent/tools.py | 4 ++ agent/workspace_api.py | 14 ++++- 11 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 agent/candidate_clone.py create mode 100644 agent/data/observed_callable.py diff --git a/agent/candidate_clone.py b/agent/candidate_clone.py new file mode 100644 index 000000000..9b8fdb5ac --- /dev/null +++ b/agent/candidate_clone.py @@ -0,0 +1,36 @@ +import json +from pathlib import Path + +from chipcompiler.runtime.workspace_api import RuntimeApiError + +_ARTIFACT_DIR_NAMES = frozenset({"output", "data", "feature", "analysis", "report", "log"}) + + +def candidate_clone_ignore(source_root: Path, target_step: str | None): + skipped_step_roots: set[Path] = set() + if target_step is not None: + try: + flow = json.loads((source_root / "home" / "flow.json").read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise RuntimeApiError("command_failed", "candidate flow state is invalid") from exc + if not isinstance(flow, dict) or not isinstance(flow.get("steps"), list): + raise RuntimeApiError("command_failed", "candidate flow state is invalid") + rerun = False + for step in flow.get("steps", []): + if not isinstance(step, dict): + continue + name, tool = step.get("name"), step.get("tool") + rerun = rerun or name == target_step + if rerun and isinstance(name, str) and isinstance(tool, str): + skipped_step_roots.add(source_root / f"{name}_{tool}") + skipped_step_roots.add(source_root / f"{'_'.join(name.split()).lower()}_{tool}") + + def ignore(directory, names): + current = Path(directory).resolve() + if current == source_root: + return {".agent"}.intersection(names) + if current in skipped_step_roots: + return _ARTIFACT_DIR_NAMES.intersection(names) + return set() + + return ignore diff --git a/agent/data/observed_callable.py b/agent/data/observed_callable.py new file mode 100644 index 000000000..2405a3e71 --- /dev/null +++ b/agent/data/observed_callable.py @@ -0,0 +1,14 @@ +from collections.abc import Callable +from typing import Any + + +class ObservedCallable: + def __init__(self, observed: Callable[..., Any], original: Callable[..., Any]) -> None: + self._observed = observed + self._original = original + + def __call__(self, *args, **kwargs): + return self._observed(*args, **kwargs) + + def __getattr__(self, name: str): + return getattr(self._original, name) diff --git a/agent/data/parameter_runtime_observer.py b/agent/data/parameter_runtime_observer.py index 81e28151f..3a0e288d3 100644 --- a/agent/data/parameter_runtime_observer.py +++ b/agent/data/parameter_runtime_observer.py @@ -15,6 +15,7 @@ sha256_path, write_json_atomic, ) +from .observed_callable import ObservedCallable DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v1" RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" @@ -201,7 +202,8 @@ def observed(*args, **kwargs): return observer(original, *args, **kwargs) previous = vars(owner).get(name, _MISSING) - setattr(owner, name, observed) + replacement = observed if isinstance(owner, type) else ObservedCallable(observed, original) + setattr(owner, name, replacement) stack.callback(_restore_attribute, owner, name, previous) diff --git a/agent/engine.py b/agent/engine.py index 5e44a3f03..2c0a11c48 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -12,6 +12,7 @@ from chipcompiler.engine.step_execution import get_process_rss_mb, track_current_process_memory from chipcompiler.utility.log import redirect_stdio_to_file +from .plot import _is_candidate_workspace from .tools import run_step as run_agent_step @@ -177,4 +178,5 @@ def _save_agent_step_facts( build_step_metrics(workspace=self.workspace, step=workspace_step) except Exception: self.workspace.logger.exception("[QOR] failed to refresh analysis") - save_layout_image(workspace=self.workspace, step=workspace_step) + if not _is_candidate_workspace(self.workspace): + save_layout_image(workspace=self.workspace, step=workspace_step) diff --git a/agent/plot.py b/agent/plot.py index 1d33b1736..6c3f96a87 100644 --- a/agent/plot.py +++ b/agent/plot.py @@ -2,9 +2,11 @@ import multiprocessing import os from collections.abc import Callable +from pathlib import Path from tqdm import tqdm +from chipcompiler.tools.ecc.plot import ECCToolsPlot from chipcompiler.utility import plot_csv_map MAX_PLOT_WORKERS = 4 @@ -30,3 +32,15 @@ def plot_array_maps(input_paths: list[str], warn: Callable[[str], None]) -> None unit="file", ): pass + + +def _is_candidate_workspace(workspace) -> bool: + root = Path(workspace.directory).resolve() + return root.parent.name == "candidates" and root.parent.parent.name == ".agent" + + +class AgentECCToolsPlot(ECCToolsPlot): + def plot(self) -> bool: + if _is_candidate_workspace(self.workspace): + return True + return super().plot() diff --git a/agent/test/test_engine.py b/agent/test/test_engine.py index b44e67405..985844919 100644 --- a/agent/test/test_engine.py +++ b/agent/test/test_engine.py @@ -81,3 +81,25 @@ def test_agent_incomplete_step_normalized_on_resume(tmp_path, monkeypatch): assert flow.run_step(flow.workspace_steps[1], rerun=False) == StateEnum.Success persisted = json.loads((home / "flow.json").read_text()) assert persisted["steps"][1]["state"] == StateEnum.Success.value + + +def test_agent_engine_skips_layout_snapshot_for_candidate_workspace(tmp_path, monkeypatch): + root = tmp_path / ".agent" / "candidates" / "candidate-1" + root.mkdir(parents=True) + workspace = Workspace(directory=root, flow=Flow(path=root / "home" / "flow.json")) + flow = AgentEngineFlow(workspace) + step = EccStep(name="place", directory=root / "place_dreamplace", tool="ecc") + calls = [] + monkeypatch.setattr(flow, "save_step_flow_facts", lambda **_kwargs: True) + monkeypatch.setattr( + "chipcompiler.tools.build_step_metrics", + lambda **_kwargs: calls.append("metrics"), + ) + monkeypatch.setattr( + "chipcompiler.tools.save_layout_image", + lambda **_kwargs: calls.append("snapshot"), + ) + + flow._save_agent_step_facts(step, StateEnum.Success, 1.0, 2.0, {}) + + assert calls == ["metrics"] diff --git a/agent/test/test_parameter_runtime_observer.py b/agent/test/test_parameter_runtime_observer.py index 1f9ba767a..a8b4f463a 100644 --- a/agent/test/test_parameter_runtime_observer.py +++ b/agent/test/test_parameter_runtime_observer.py @@ -299,6 +299,38 @@ def run(self): assert Owner.run is original +def test_scoped_callable_hook_preserves_wrapped_operator_methods(): + class Operation: + __name__ = "density_op" + __qualname__ = "Operation.density_op" + __annotations__ = {} + + def __init__(self): + self.reset_calls = 0 + + def __call__(self): + return "original" + + def reset(self): + self.reset_calls += 1 + + owner = SimpleNamespace(density_op=Operation()) + original = owner.density_op + + with ExitStack() as stack: + _patch_method( + stack, + owner, + "density_op", + lambda wrapped: (wrapped(), "observed"), + ) + assert owner.density_op() == ("original", "observed") + owner.density_op.reset() + + assert original.reset_calls == 1 + assert owner.density_op is original + + def test_native_model_hook_records_density_updates_and_routability_calls(): recorder = DreamplaceRecorder( patch={"knob_id": "place.density_weight", "value": 0.001}, diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index 90477161f..f99d11753 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -7,6 +7,9 @@ from agent import tools as eda from agent.data import parameter_runtime_observer as runtime_observer from agent.data.candidate_materialization import materialize_candidate_config +from agent.plot import AgentECCToolsPlot +from chipcompiler.tools.ecc import runner as ecc_runner +from chipcompiler.tools.ecc.plot import ECCToolsPlot def _write_json(path: Path, data: dict) -> None: @@ -14,6 +17,19 @@ def _write_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data), encoding="utf-8") +def test_agent_plotter_skips_all_display_plots_for_candidate_workspaces(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(ECCToolsPlot, "plot", lambda plotter: calls.append(plotter.workspace)) + candidate = SimpleNamespace(directory=tmp_path / ".agent" / "candidates" / "candidate-1") + ordinary = SimpleNamespace(directory=tmp_path / "ordinary") + + assert AgentECCToolsPlot(candidate, SimpleNamespace()).plot() is True + AgentECCToolsPlot(ordinary, SimpleNamespace()).plot() + + assert calls == [ordinary] + assert ecc_runner.ECCToolsPlot is AgentECCToolsPlot + + class _Scalar: def __init__(self, value): self.value = value diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 859769b54..891b749f7 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -13,6 +13,7 @@ _candidate_rerun_result, _candidate_rerun_steps, _candidate_step_artifact_dirs, + _create_candidate_workspace, _materialize_candidate_rerun, _reject_workspace_symlinks, build_agent_flow_for_workspace, @@ -36,6 +37,58 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) +def test_candidate_clone_skips_step_directories_that_will_be_rerun(tmp_path): + flow_data = { + "steps": [ + {"name": "Floorplan", "tool": "ecc"}, + {"name": "place", "tool": "dreamplace"}, + {"name": "Timing optimization", "tool": "sizer"}, + {"name": "Harden", "tool": "ecc"}, + ] + } + home = tmp_path / "home" + home.mkdir() + (home / "flow.json").write_text(json.dumps(flow_data), encoding="utf-8") + for directory in ( + "Floorplan_ecc", + "place_dreamplace", + "timing_optimization_sizer", + "Harden_ecc", + ): + path = tmp_path / directory + path.mkdir() + (path / "checklist.json").write_text("{}", encoding="utf-8") + output = path / "output" + output.mkdir() + (output / "artifact").write_bytes(b"x" * 1024) + workspace = SimpleNamespace(directory=tmp_path) + + candidate, _, _ = _create_candidate_workspace( + _EccApi(workspace), workspace, "candidate-1", None, "place" + ) + candidate_root = Path(candidate.directory) + + assert (candidate_root / "Floorplan_ecc" / "output" / "artifact").is_file() + for directory in ( + "place_dreamplace", + "timing_optimization_sizer", + "Harden_ecc", + ): + step_root = candidate_root / directory + assert (step_root / "checklist.json").is_file() + assert not (step_root / "output").exists() + + +def test_candidate_clone_rejects_invalid_flow_state(tmp_path): + home = tmp_path / "home" + home.mkdir() + (home / "flow.json").write_text("[]", encoding="utf-8") + workspace = SimpleNamespace(directory=tmp_path) + + with pytest.raises(RuntimeApiError, match="candidate flow state is invalid"): + _create_candidate_workspace(_EccApi(workspace), workspace, "candidate-1", None, "place") + + @pytest.mark.parametrize( "target_step,expected_first", [ diff --git a/agent/tools.py b/agent/tools.py index 567d3eec2..f34371a9c 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,10 +1,14 @@ from chipcompiler.data import Workspace, WorkspaceStep, log_workspace_step +from chipcompiler.tools.ecc import runner as ecc_runner from chipcompiler.tools.eda import load_eda_module from .data import reapply_materialized_candidate_config from .data.parameter_runtime_observer import run_with_parameter_observation +from .plot import AgentECCToolsPlot from .runtime_env import isolated_sizer_loader_environment +ecc_runner.ECCToolsPlot = AgentECCToolsPlot + def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: eda_module = load_eda_module(step.tool, check_dependency=step.tool != "sizer") diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 132fa239b..294629afe 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -16,6 +16,7 @@ ) from chipcompiler.utility.path import path_is_within +from .candidate_clone import candidate_clone_ignore from .data import ( FoundationExtractor, bind_candidate_input, @@ -156,6 +157,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> session.workspace, request.candidate_id, request.parent_candidate_root_ref, + request.target_step, ) flow = None try: @@ -330,7 +332,11 @@ def _notify_candidate_rerun_prepared(observer, steps: list, request: CandidateRe def _create_candidate_workspace( - ecc_api, workspace, candidate_id: str, parent_candidate_root_ref: str | None = None + ecc_api, + workspace, + candidate_id: str, + parent_candidate_root_ref: str | None = None, + target_step: str | None = None, ): workspace_root = _parent_workspace_root(workspace) _reject_workspace_symlinks(_candidate_parent_root(workspace_root, parent_candidate_root_ref)) @@ -347,7 +353,11 @@ def _create_candidate_workspace( if candidate_root.exists() or candidate_root.is_symlink(): raise RuntimeApiError("command_failed", "candidate workspace already exists") try: - shutil.copytree(source_root, candidate_root, ignore=shutil.ignore_patterns(".agent")) + shutil.copytree( + source_root, + candidate_root, + ignore=candidate_clone_ignore(source_root, target_step), + ) except OSError as exc: _remove_failed_candidate_workspace(candidate_root) raise RuntimeApiError("command_failed", f"candidate workspace clone failed: {exc}") from exc From 5a48fcfc17ca3eacc0eabbc54899fe1a8877c31a Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 6 Sep 2026 11:26:41 +0800 Subject: [PATCH 69/90] perf: preflight agent sizer candidates --- agent/runtime_env.py | 38 ++++++++++++++ agent/test/test_runtime.py | 39 ++++++++++++++- agent/test/test_workspace_api.py | 85 ++++++++++++++++++++++++++++++-- agent/workspace_api.py | 71 +++++++++++++++++++++----- 4 files changed, 216 insertions(+), 17 deletions(-) diff --git a/agent/runtime_env.py b/agent/runtime_env.py index 7d7706bf9..564aab842 100644 --- a/agent/runtime_env.py +++ b/agent/runtime_env.py @@ -1,6 +1,7 @@ """Process environment preparation for the opt-in Agent runtime.""" import os +import subprocess from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path @@ -13,6 +14,10 @@ ) +class SizerRuntimePreflightError(RuntimeError): + pass + + def _packaged_sizer_executable() -> Path | None: root_value = os.environ.get("CHIPCOMPILER_ECC_SIZER_ROOT", "").strip() if not root_value: @@ -40,6 +45,39 @@ def prepare_agent_runtime_environment() -> None: os.environ["PATH"] = os.pathsep.join((binary_dir, *filter(None, path_entries))) +def preflight_sizer_runtime(timeout_seconds: float = 5.0) -> None: + from chipcompiler.tools.ecc_sizer.utility import get_sizer_command, is_sizer_runtime_exist + + command = get_sizer_command() + if not command or not is_sizer_runtime_exist(): + raise SizerRuntimePreflightError("Sizer runtime is unavailable") + + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + env.pop("LD_PRELOAD", None) + try: + result = subprocess.run( + [*command, "-env", os.devnull, "-f", os.devnull], + capture_output=True, + check=False, + env=env, + text=True, + timeout=timeout_seconds, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SizerRuntimePreflightError("Sizer runtime preflight failed") from exc + if result.returncode != 0: + detail = next( + ( + line.strip() + for line in (*result.stderr.splitlines(), *result.stdout.splitlines()) + if line + ), + "unknown startup failure", + ) + raise SizerRuntimePreflightError(f"Sizer runtime preflight failed: {detail[:512]}") + + @contextmanager def isolated_sizer_loader_environment() -> Iterator[None]: if _packaged_sizer_executable() is None: diff --git a/agent/test/test_runtime.py b/agent/test/test_runtime.py index d8d337364..d9762e354 100644 --- a/agent/test/test_runtime.py +++ b/agent/test/test_runtime.py @@ -4,7 +4,11 @@ import pytest -from agent.runtime_env import prepare_agent_runtime_environment +from agent.runtime_env import ( + SizerRuntimePreflightError, + preflight_sizer_runtime, + prepare_agent_runtime_environment, +) from chipcompiler.runtime.operations import RuntimeOperationFailed, RuntimeOperationManager @@ -47,6 +51,39 @@ def test_agent_runtime_without_packaged_sizer_preserves_environment(tmp_path, mo assert os.environ["LD_PRELOAD"] == "/host/preload.so" +def test_sizer_runtime_preflight_accepts_launchable_runtime(tmp_path, monkeypatch): + runtime_root = tmp_path / "ecc-sizer" + executable = runtime_root / "bin" / "Sizer" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + sentinel = runtime_root / "src" / "sizer_os.tcl" + sentinel.parent.mkdir() + sentinel.write_text("", encoding="utf-8") + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + prepare_agent_runtime_environment() + + preflight_sizer_runtime() + + +def test_sizer_runtime_preflight_rejects_broken_runtime(tmp_path, monkeypatch): + runtime_root = tmp_path / "ecc-sizer" + executable = runtime_root / "bin" / "Sizer" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\necho broken runtime >&2\nexit 1\n", encoding="utf-8") + executable.chmod(0o755) + sentinel = runtime_root / "src" / "sizer_os.tcl" + sentinel.parent.mkdir() + sentinel.write_text("", encoding="utf-8") + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + prepare_agent_runtime_environment() + + with pytest.raises(SizerRuntimePreflightError, match="broken runtime"): + preflight_sizer_runtime() + + def test_structured_candidate_failure_preserves_partial_result() -> None: events = [] manager = RuntimeOperationManager(events.append) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 891b749f7..abbae49e8 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -15,6 +15,7 @@ _candidate_step_artifact_dirs, _create_candidate_workspace, _materialize_candidate_rerun, + _preflight_candidate_steps, _reject_workspace_symlinks, build_agent_flow_for_workspace, ) @@ -37,6 +38,81 @@ def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): assert _candidate_step_artifact_dirs(step) == (Path(output_dir), Path(analysis_dir)) +def test_candidate_preflight_checks_sizer_once(monkeypatch): + calls = [] + monkeypatch.setattr( + "agent.workspace_api.preflight_sizer_runtime", lambda: calls.append("sizer") + ) + + _preflight_candidate_steps( + [ + SimpleNamespace(tool="ecc"), + SimpleNamespace(tool="sizer"), + SimpleNamespace(tool="ecc"), + ] + ) + + assert calls == ["sizer"] + + +def test_candidate_preflight_skips_sizer_check_when_step_range_excludes_it(monkeypatch): + calls = [] + monkeypatch.setattr( + "agent.workspace_api.preflight_sizer_runtime", lambda: calls.append("sizer") + ) + + _preflight_candidate_steps([SimpleNamespace(tool="ecc")]) + + assert calls == [] + + +def test_candidate_sizer_preflight_failure_skips_clone(monkeypatch, tmp_path): + workspace = SimpleNamespace( + directory=tmp_path, + flow=SimpleNamespace( + data={ + "steps": [ + {"name": "place", "tool": "dreamplace"}, + {"name": "Timing optimization", "tool": "sizer"}, + {"name": "Harden", "tool": "ecc"}, + ] + } + ), + ) + api = FlowAgentRuntimeApi(_EccApi(workspace)) + monkeypatch.setattr( + api, + "_build_flow", + lambda *_args, **_kwargs: pytest.fail("preflight must not build the parent flow"), + ) + monkeypatch.setattr( + "agent.workspace_api.preflight_sizer_runtime", + lambda: (_ for _ in ()).throw(RuntimeError("sizer broken")), + ) + monkeypatch.setattr( + "agent.workspace_api._create_candidate_workspace", + lambda *_args: pytest.fail("candidate clone must wait for Sizer preflight"), + ) + + operation = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + terminal = _wait_for_terminal(api.ecc_api.operations, operation["operationId"], "failed") + assert "sizer broken" in terminal["error"]["message"] + + def test_candidate_clone_skips_step_directories_that_will_be_rerun(tmp_path): flow_data = { "steps": [ @@ -301,9 +377,10 @@ def materialize(candidate_workspace, target, patch, candidate): candidate_root = tmp_path / ".agent" / "candidates" / "candidate-1" candidate_root_ref = ".agent/candidates/candidate-1" candidate_manifest_ref = f"{candidate_root_ref}/analysis/candidate_workspace.v1.json" - assert flows[0].run_calls == [("place", True), ("CTS", True), ("Harden", True)] - assert flows[0].created is True - assert flows[0].initialize_config is False + candidate_flow = flows[-1] + assert candidate_flow.run_calls == [("place", True), ("CTS", True), ("Harden", True)] + assert candidate_flow.created is True + assert candidate_flow.initialize_config is False assert flow_path.read_bytes() == parent_flow_bytes assert config_path.read_text(encoding="utf-8") == '{"target_density": 0.5}\n' assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() @@ -313,7 +390,7 @@ def materialize(candidate_workspace, target, patch, candidate): assert json.loads( (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") ) == {"random_seed": 17, "target_density": 0.6} - assert flows[0].observed_random_seeds == [17] + assert candidate_flow.observed_random_seeds == [17] assert not list((candidate_root / "place_dreamplace" / "output").iterdir()) assert not list((candidate_root / "place_dreamplace" / "analysis").iterdir()) assert not list((candidate_root / "CTS_ecc" / "output").iterdir()) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 294629afe..6e85047c9 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -40,6 +40,7 @@ CandidateResumeRequest, WorkspaceExtractFoundationRequest, ) +from .runtime_env import preflight_sizer_runtime def _stable_hash(value) -> str: @@ -152,27 +153,35 @@ def candidate_resume(self, request: CandidateResumeRequest) -> dict: return candidate_resume(self, request) def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: - candidate_workspace, candidate_root_ref, parent = _create_candidate_workspace( - self.ecc_api, - session.workspace, - request.candidate_id, - request.parent_candidate_root_ref, - request.target_step, - ) + candidate_workspace = None + candidate_root_ref = None + parent = None flow = None try: + preflight_done = self._preflight_candidate_rerun_before_clone( + session.workspace, request + ) + candidate_workspace, candidate_root_ref, parent = _create_candidate_workspace( + self.ecc_api, + session.workspace, + request.candidate_id, + request.parent_candidate_root_ref, + request.target_step, + ) flow = self._build_flow(candidate_workspace, create_step_workspaces=False) create_step_workspaces = getattr(flow, "create_step_workspaces", None) if callable(create_step_workspaces): create_step_workspaces(initialize_config=False) - if request.patch: - _materialize_candidate_rerun(candidate_workspace, flow, request) steps = _candidate_rerun_steps( flow, request.target_step, request.end_step, request.execution_scope, ) + if not preflight_done: + _preflight_candidate_steps(steps) + if request.patch: + _materialize_candidate_rerun(candidate_workspace, flow, request) _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: @@ -221,6 +230,21 @@ def _build_flow(self, workspace, *, create_step_workspaces: bool = True): flow = build_agent_flow_for_workspace(workspace) return flow + def _preflight_candidate_rerun_before_clone( + self, workspace, request: CandidateRerunRequest + ) -> bool: + try: + steps = _candidate_step_range( + workspace.flow.data["steps"], + request.target_step, + request.end_step, + request.execution_scope, + ) + except (AttributeError, KeyError, TypeError): + return False + _preflight_candidate_steps(steps) + return True + def _with_workspace_lock(self, workspace_id: str, operation): return self.ecc_api._with_session_mutation_lock(workspace_id, operation) @@ -244,13 +268,27 @@ def _foundation_receipt(workspace_dir: Path) -> dict: def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scope: str) -> list: + return _candidate_step_range( + list(getattr(flow, "workspace_steps", ())), + target_step, + end_step, + execution_scope, + ) + + +def _candidate_step_range( + steps: list, target_step: str, end_step: str, execution_scope: str +) -> list: if execution_scope not in {"single_step", "full_flow"}: raise RuntimeApiError("invalid_request", "candidate rerun execution scope is invalid") - steps = list(getattr(flow, "workspace_steps", ())) target_index = next( - (index for index, step in enumerate(steps) if step.name == target_step), None + (index for index, step in enumerate(steps) if _step_value(step, "name") == target_step), + None, + ) + end_index = next( + (index for index, step in enumerate(steps) if _step_value(step, "name") == end_step), + None, ) - end_index = next((index for index, step in enumerate(steps) if step.name == end_step), None) if target_index is None or end_index is None: raise RuntimeApiError( "command_failed", f"rerun step not found: {target_step} or {end_step}" @@ -266,6 +304,10 @@ def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scop return steps[target_index : end_index + 1] +def _step_value(step, field: str): + return step.get(field) if isinstance(step, dict) else getattr(step, field, None) + + _IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") @@ -942,6 +984,11 @@ def _prepare_candidate_rerun(workspace, flow, steps: list) -> None: flow.save() +def _preflight_candidate_steps(steps: list) -> None: + if any(_step_value(step, "tool") == "sizer" for step in steps): + preflight_sizer_runtime() + + def _candidate_step_artifact_dirs(step) -> tuple[Path, ...]: directories = [] for field in ("output", "data", "feature", "analysis", "report", "log"): From 254d74c5b2f5da4b07f359cf8c8e97e8e82f0d10 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 6 Sep 2026 11:53:29 +0800 Subject: [PATCH 70/90] fix: skip lec for agent candidate inputs --- agent/test/test_workspace_api.py | 19 +++++++++++++++++++ agent/workspace_api.py | 9 ++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index abbae49e8..62e37b847 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -12,6 +12,7 @@ FlowAgentRuntimeApi, _candidate_rerun_result, _candidate_rerun_steps, + _candidate_source_step, _candidate_step_artifact_dirs, _create_candidate_workspace, _materialize_candidate_rerun, @@ -194,6 +195,24 @@ def test_candidate_rerun_slice_starts_at_the_modified_stage( assert steps[-1].name == "Harden" +def test_floorplan_candidate_uses_synthesis_checkpoint_across_lec() -> None: + flow = SimpleNamespace( + workspace=SimpleNamespace( + flow=SimpleNamespace( + data={ + "steps": [ + {"name": "Synthesis", "tool": "yosys"}, + {"name": "lec", "tool": "yosys_lec"}, + {"name": "Floorplan", "tool": "ecc"}, + ] + } + ) + ) + ) + + assert _candidate_source_step(flow, "Floorplan") == "Synthesis" + + def test_agent_flow_defaults_to_harden_flow(monkeypatch): class RecordingFlow: def __init__(self, workspace): diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 6e85047c9..5a6a92901 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -955,9 +955,12 @@ def _candidate_source_step(flow, target_step: str) -> str: steps = flow.workspace.flow.data.get("steps", []) for index, step in enumerate(steps): if step.get("name") == target_step and index: - source = steps[index - 1].get("name") - if isinstance(source, str): - return source + for source_step in reversed(steps[:index]): + if source_step.get("tool") == "yosys_lec": + continue + source = source_step.get("name") + if isinstance(source, str): + return source raise RuntimeApiError("invalid_request", f"candidate target has no predecessor: {target_step}") From 4feb98480d38ad0af7a4ff7d9fdfecabf9444301 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 6 Sep 2026 12:04:33 +0800 Subject: [PATCH 71/90] fix: bind agent receipts to loaded pdk --- .../test/test_parameter_receipt_artifacts.py | 24 +++++++++++++++++++ agent/workspace_api.py | 4 +--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 58d4c7a81..6e6845619 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -170,6 +170,30 @@ def test_parameter_receipt_context_aggregates_all_rtl_and_sdc_files(tmp_path: Pa ) +def test_parameter_receipt_context_uses_loaded_pdk_tech_without_legacy_json( + tmp_path: Path, +) -> None: + workspace, _ = _materialized_workspace( + tmp_path, + candidate_id="candidate-canonical-config", + knob_id="place.target_density", + before=0.5, + written=0.85, + ) + (tmp_path / "home" / "parameters.json").unlink() + request = SimpleNamespace( + candidate_id="candidate-canonical-config", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + seed=17, + ) + + context = _parameter_receipt_context(workspace, request, HASH) + + assert context["pdk_sha256"] == sha256_path(workspace.pdk.tech) + assert context["site_width_dbu"] == 200 + + def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monkeypatch) -> None: workspace, materialization = _materialized_workspace( tmp_path, diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 5a6a92901..b90a5d382 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -809,9 +809,7 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d if not rtl_files or not sdc_files: raise RuntimeApiError("command_failed", "candidate input fingerprints are unavailable") try: - parameters = json.loads((root / "home" / "parameters.json").read_text(encoding="utf-8")) - pdk_root = Path(parameters["PDK Root"]) - tech_lef = pdk_root / "prtech" / "techLEF" / "N551P6M_ecos.lef" + tech_lef = Path(getattr(getattr(workspace, "pdk", None), "tech", None)) pdk_sha256 = f"sha256:{sha256(tech_lef.read_bytes()).hexdigest()}" lef_text = tech_lef.read_text(encoding="utf-8") units_match = re.search(r"DATABASE\s+MICRONS\s+(\d+)", lef_text, re.IGNORECASE) From 5cf42f801a8baa64c10dac7440bed36d386c576c Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 6 Sep 2026 15:55:41 +0800 Subject: [PATCH 72/90] perf: skip agent display plots in headless runs --- agent/plot.py | 8 ++++++++ agent/test/test_tools.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/agent/plot.py b/agent/plot.py index 6c3f96a87..2db03f5df 100644 --- a/agent/plot.py +++ b/agent/plot.py @@ -44,3 +44,11 @@ def plot(self) -> bool: if _is_candidate_workspace(self.workspace): return True return super().plot() + + def plot_array_maps(self, input_paths: list[str]) -> None: + if _is_candidate_workspace(self.workspace): + return + if os.environ.get("ECOS_AGENT_SKIP_DISPLAY_PLOTS") == "1": + plot_array_maps(input_paths, self.workspace.logger.warning) + return + super().plot_array_maps(input_paths) diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index f99d11753..3fbb02787 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -30,6 +30,21 @@ def test_agent_plotter_skips_all_display_plots_for_candidate_workspaces(monkeypa assert ecc_runner.ECCToolsPlot is AgentECCToolsPlot +def test_agent_plotter_uses_headless_display_helper_only_when_requested(monkeypatch, tmp_path): + calls = [] + ordinary = SimpleNamespace( + directory=tmp_path / "ordinary", + logger=SimpleNamespace(warning=lambda message: calls.append(message)), + ) + monkeypatch.setenv("ECOS_AGENT_SKIP_DISPLAY_PLOTS", "1") + monkeypatch.setattr("agent.plot.plot_array_maps", lambda paths, warn: calls.append(paths)) + monkeypatch.setattr(ECCToolsPlot, "plot_array_maps", lambda *_args: calls.append("default")) + + AgentECCToolsPlot(ordinary, SimpleNamespace()).plot_array_maps(["map.csv"]) + + assert calls == [["map.csv"]] + + class _Scalar: def __init__(self, value): self.value = value From 5a52d21599ec3bdc241a4648ce25e4974c8110ec Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 6 Sep 2026 18:55:23 +0800 Subject: [PATCH 73/90] fix: preflight agent runtimes before candidate execution --- agent/methods.py | 6 ++++++ agent/requests.py | 5 +++++ agent/runtime_env.py | 5 +++++ agent/test/test_requests.py | 21 ++++++++++++++++++++- agent/test/test_runtime.py | 29 +++++++++++++++++++++++++++++ agent/workspace_api.py | 4 ++++ 6 files changed, 69 insertions(+), 1 deletion(-) diff --git a/agent/methods.py b/agent/methods.py index cb4a3aff1..66da427b6 100644 --- a/agent/methods.py +++ b/agent/methods.py @@ -8,10 +8,16 @@ CandidateMaterializeRequest, CandidateRerunRequest, CandidateResumeRequest, + RuntimePreflightRequest, WorkspaceExtractFoundationRequest, ) AGENT_RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( + RuntimeMethodSpec( + method_name="agent.runtime_preflight", + request_model=RuntimePreflightRequest, + handler_name="runtime_preflight", + ), RuntimeMethodSpec( method_name="workspace.extract_foundation", request_model=WorkspaceExtractFoundationRequest, diff --git a/agent/requests.py b/agent/requests.py index 265078432..5efa0d9b0 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -4,6 +4,11 @@ from chipcompiler.runtime.requests import RequestValidationError, parse_request_model +@dataclass(frozen=True) +class RuntimePreflightRequest: + pass + + @dataclass(frozen=True) class WorkspaceExtractFoundationRequest: workspace_id: str diff --git a/agent/runtime_env.py b/agent/runtime_env.py index 564aab842..bd3da7804 100644 --- a/agent/runtime_env.py +++ b/agent/runtime_env.py @@ -52,6 +52,11 @@ def preflight_sizer_runtime(timeout_seconds: float = 5.0) -> None: if not command or not is_sizer_runtime_exist(): raise SizerRuntimePreflightError("Sizer runtime is unavailable") + from chipcompiler.tools.ecc_dreamplace.utility import is_eda_exist as is_dreamplace_exist + + if not is_dreamplace_exist(): + raise SizerRuntimePreflightError("DreamPlace runtime is unavailable") + env = os.environ.copy() env.pop("LD_LIBRARY_PATH", None) env.pop("LD_PRELOAD", None) diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 31ab156e5..0f7df248e 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -6,7 +6,12 @@ import pytest from agent.methods import agent_method_names -from agent.requests import CandidateRerunRequest, CandidateResumeRequest, parse_agent_request_model +from agent.requests import ( + CandidateRerunRequest, + CandidateResumeRequest, + RuntimePreflightRequest, + parse_agent_request_model, +) from agent.server import AgentRuntimeServer from chipcompiler.runtime.requests import RequestValidationError from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame @@ -17,6 +22,7 @@ def test_agent_methods_keep_the_original_rpc_names(): assert agent_method_names() == ( + "agent.runtime_preflight", "workspace.extract_foundation", "candidate.export_capabilities", "candidate.bind_input", @@ -32,6 +38,19 @@ def test_agent_runtime_server_registers_isolated_methods(): assert set(agent_method_names()).issubset(server.capabilities) +def test_runtime_preflight_is_read_only_and_checks_agent_tools(monkeypatch): + calls = [] + monkeypatch.setattr( + "agent.workspace_api.preflight_sizer_runtime", lambda: calls.append("preflight") + ) + server = AgentRuntimeServer() + + result = server.agent_api.runtime_preflight(RuntimePreflightRequest()) + + assert result == {"sizer": True, "dreamplace": True} + assert calls == ["preflight"] + + def test_agent_runtime_server_prepares_agent_environment(monkeypatch): calls = [] monkeypatch.setattr( diff --git a/agent/test/test_runtime.py b/agent/test/test_runtime.py index d9762e354..d009b0d3a 100644 --- a/agent/test/test_runtime.py +++ b/agent/test/test_runtime.py @@ -63,6 +63,10 @@ def test_sizer_runtime_preflight_accepts_launchable_runtime(tmp_path, monkeypatc monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) prepare_agent_runtime_environment() + monkeypatch.setattr( + "chipcompiler.tools.ecc_dreamplace.utility.is_eda_exist", + lambda: True, + ) preflight_sizer_runtime() @@ -79,11 +83,36 @@ def test_sizer_runtime_preflight_rejects_broken_runtime(tmp_path, monkeypatch): monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) prepare_agent_runtime_environment() + monkeypatch.setattr( + "chipcompiler.tools.ecc_dreamplace.utility.is_eda_exist", + lambda: True, + ) with pytest.raises(SizerRuntimePreflightError, match="broken runtime"): preflight_sizer_runtime() +def test_sizer_runtime_preflight_rejects_missing_dreamplace_runtime(tmp_path, monkeypatch): + runtime_root = tmp_path / "ecc-sizer" + executable = runtime_root / "bin" / "Sizer" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + sentinel = runtime_root / "src" / "sizer_os.tcl" + sentinel.parent.mkdir() + sentinel.write_text("", encoding="utf-8") + monkeypatch.setenv("CHIPCOMPILER_ECC_SIZER_ROOT", str(runtime_root)) + monkeypatch.setenv("PATH", str(tmp_path / "empty-path")) + prepare_agent_runtime_environment() + monkeypatch.setattr( + "chipcompiler.tools.ecc_dreamplace.utility.is_eda_exist", + lambda: False, + ) + + with pytest.raises(SizerRuntimePreflightError, match="DreamPlace runtime is unavailable"): + preflight_sizer_runtime() + + def test_structured_candidate_failure_preserves_partial_result() -> None: events = [] manager = RuntimeOperationManager(events.append) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index b90a5d382..4fef11003 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -86,6 +86,10 @@ class FlowAgentRuntimeApi: def __init__(self, ecc_api: WorkspaceRuntimeApi): self.ecc_api = ecc_api + def runtime_preflight(self, _request) -> dict[str, bool]: + preflight_sizer_runtime() + return {"sizer": True, "dreamplace": True} + def extract_foundation(self, request: WorkspaceExtractFoundationRequest) -> dict: def extract(session): workspace_dir = Path(session.workspace.directory).resolve() From b532d754d4871556a28459da3ab16cc7990837fa Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Mon, 7 Sep 2026 10:38:50 +0800 Subject: [PATCH 74/90] perf: parallelize controlled candidate STA corners --- agent/STA_PARALLEL.md | 96 +++++++++++ agent/engine.py | 13 +- agent/sta_benchmark.py | 222 ++++++++++++++++++++++++ agent/sta_parallel.py | 202 ++++++++++++++++++++++ agent/test/test_sta_benchmark.py | 58 +++++++ agent/test/test_sta_parallel.py | 285 +++++++++++++++++++++++++++++++ agent/test/test_tools.py | 25 +++ agent/tools.py | 4 + 8 files changed, 902 insertions(+), 3 deletions(-) create mode 100644 agent/STA_PARALLEL.md create mode 100644 agent/sta_benchmark.py create mode 100644 agent/sta_parallel.py create mode 100644 agent/test/test_sta_benchmark.py create mode 100644 agent/test/test_sta_parallel.py diff --git a/agent/STA_PARALLEL.md b/agent/STA_PARALLEL.md new file mode 100644 index 000000000..c8f96bbde --- /dev/null +++ b/agent/STA_PARALLEL.md @@ -0,0 +1,96 @@ +# Controlled Candidate STA Parallelism + +Only Agent candidate workspaces (`.agent/candidates/`) use this scheduler. +Ordinary flows and Harden retain their existing execution path. No corners, +reports, timing constraints, power calculations, or metric aggregation rules +are removed. All implementation changes are owned by `ecc/agent`. + +## Configuration + +Set `ECOS_AGENT_STA_WORKERS` in the environment that launches the Agent/RPC: + +- `1`: original serial runner, also the non-Linux default. +- `2`: Linux default, two independent corner processes. +- `4`: four independent corner processes, with higher memory consumption. + +Other values fail validation. Parallel execution requires Linux. Restart an +already-running Agent/RPC after changing its launch environment. + +Each corner uses a fresh spawned native process, an identical database snapshot, +and isolated temporary directories. The existing ECC runner still validates +inputs, enumerates all configured corners, aggregates metrics, and runs checks. +Artifacts are published only after all corner jobs succeed; normal failures +clear stale corner/aggregate outputs and reap remaining workers. Cancellation +is checked while jobs run. Linux parent-death signals kill workers if the RPC +parent is terminated without Python cleanup. Forced parent termination can +leave temporary directories; it does not guarantee filesystem cleanup. + +Per-corner logs remain in `sta_ecc/log/sta-corner-.log`. Agent STA memory +tracking includes descendants, retaining the existing increase-from-start +metric convention. The benchmark instead reports absolute sampled process-tree +RSS; shared resident pages may be counted more than once. + +## GCD Measurement + +Measured on 2026-09-07, Linux x86_64, AMD EPYC 9654 (384 logical CPUs), Python +3.11.14, installed `ecc=0.1.0a11`, `ecc-tools-bin=0.1.0a12`. Two runs per +configuration, serial/2/4 order repeated, on separate copies of one routed +gcd/ICS55 workspace. Cache state and machine load were not controlled. + +| Workers | STA median (s) | STA speedup | Harden median (s) | Maximum sampled tree RSS (MiB) | +| --- | ---: | ---: | ---: | ---: | +| 1 | 157.62 | 1.00x | 11.96 | 1367.00 | +| 2 | 95.76 | 1.65x | 11.74 | 2883.39 | +| 4 | 54.58 | 2.89x | 11.87 | 5555.70 | + +All six runs produced 13 timing summaries and 13 power summaries, with zero +missing configured corners. Per-corner timing/power JSON, aggregate STA/Harden +metrics, and checklist states matched the serial reference. Float comparison +uses absolute/relative tolerance `1e-9`; integer counts and key sets are exact. +Runtime and memory metrics are excluded from equivalence comparisons. + +The source workspace inventory was unchanged. Raw results and individual run +logs are in `/tmp/ecos-sta-parallel-gcd-20260907-v2`; these temporary artifacts +are not committed. Reproduce from the ECC repository with a new output path: + +```bash +./.venv/bin/python -m agent.sta_benchmark \ + --source /tmp/ecos-agent-gcd-7knob-20260906-v5/gcd-gap-4c403cfe-v12/baseline-1/workspace \ + --output /tmp/ecos-sta-parallel-gcd-reproduce \ + --workers 1 2 4 --repeats 2 +``` + +The benchmark records source hashes, implementation hashes, package/runtime +metadata, per-run metrics and logs. The initial six-run measurement predates +automatic environment metadata and the parent-death guard; its environment is +recorded above. A further guarded 2-worker native STA/Harden run completed in +95.90/12.06 seconds and matched the serial metrics; evidence is under +`/tmp/ecos-sta-parallel-guard-20260907/.agent/candidates/guard-w2`. +External PDK/library contents are not included in the workspace +inventory. No seed is added and upstream placement/routing is not rerun. + +## Acceptance Limits + +This verifies STA/Harden numerical equivalence and scheduling speed, not a new +floorplan-to-Harden optimization episode, GUI/RPC end-to-end acceptance, or +signoff. The baseline has DRC=4. Both serial and parallel runs have the same +blocked `report.sta.timing_reports` checklist item: the checker expects +`timing_max.rpt`, while native output contains `timing_max_.rpt`. +That existing report/checker mismatch is not changed or hidden here. + +Packaged/frozen execution and release builds have not been validated. Corner +reduction and native Liberty/model reuse are intentionally not implemented. + +Validation commands (from `ecc/`): + +```bash +./.venv/bin/python -m pytest -q agent/test -p no:cacheprovider +./.venv/bin/python -m pytest -q test/tools/ecc -p no:cacheprovider +./.venv/bin/ruff check agent/sta_parallel.py agent/sta_benchmark.py agent/engine.py agent/tools.py agent/test/test_sta_parallel.py agent/test/test_sta_benchmark.py agent/test/test_tools.py +git diff --check +``` + +Results: 270 Agent tests and 119 ECC tool tests passed; Ruff and whitespace +checks passed. The Agent suite includes concurrent, unrelated floorplan tests +present in the working tree. Parent-termination tests exercise both SIGTERM +and SIGKILL, including actual worker death and reaping by an isolated subreaper. diff --git a/agent/engine.py b/agent/engine.py index 2c0a11c48..4cbe44484 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -13,6 +13,7 @@ from chipcompiler.utility.log import redirect_stdio_to_file from .plot import _is_candidate_workspace +from .sta_parallel import track_sta_process_memory from .tools import run_step as run_agent_step @@ -53,7 +54,9 @@ def run_step( self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) _notify_flow_observer(observer, "on_step_started", workspace_step) self._redirect_step_stdio(workspace_step) - start_memory, peak_memory, stop_monitor, monitor = self._start_memory_monitor() + start_memory, peak_memory, stop_monitor, monitor = self._start_memory_monitor( + workspace_step + ) result = False previous_observer = getattr(self.workspace, "_runtime_flow_observer", None) if observer is not None: @@ -103,12 +106,16 @@ def _redirect_step_stdio(self, workspace_step: WorkspaceStep) -> None: except Exception: traceback.print_exc() - def _start_memory_monitor(self) -> tuple[float, list[float], Event, Thread]: + def _start_memory_monitor(self, step) -> tuple[float, list[float], Event, Thread]: start_memory = get_process_rss_mb(os.getpid()) peak_memory = [start_memory] stop_monitor = Event() monitor = Thread( - target=track_current_process_memory, + target=( + track_sta_process_memory + if step.name == "sta" and _is_candidate_workspace(self.workspace) + else track_current_process_memory + ), args=(os.getpid(), stop_monitor, peak_memory), daemon=True, ) diff --git a/agent/sta_benchmark.py b/agent/sta_benchmark.py new file mode 100644 index 000000000..58673e32e --- /dev/null +++ b/agent/sta_benchmark.py @@ -0,0 +1,222 @@ +"""Compare full-corner STA schedules on isolated copies of one routed workspace.""" + +import argparse +import hashlib +import importlib.metadata +import json +import math +import multiprocessing +import os +import platform +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def _inventory(root): + if not root.is_dir(): + raise ValueError("benchmark source must be an existing workspace directory") + result = {} + for directory, names, files in os.walk(root): + names[:] = sorted(name for name in names if name != ".agent") + if any((Path(directory) / name).is_symlink() for name in names): + raise ValueError("benchmark source contains a directory symlink") + for name in sorted(files): + path = Path(directory) / name + if path.is_symlink(): + raise ValueError(f"benchmark source contains a symlink: {path}") + with path.open("rb") as stream: + result[str(path.relative_to(root))] = hashlib.file_digest( + stream, "sha256" + ).hexdigest() + return result + + +def _tree_rss_mb(pid): + pending, visited, rss = [pid], set(), 0 + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + try: + rss += int(Path(f"/proc/{current}/statm").read_text().split()[1]) + for path in Path(f"/proc/{current}/task").glob("*/children"): + pending.extend(int(value) for value in path.read_text().split()) + except (FileNotFoundError, ProcessLookupError): + pass + return rss * os.sysconf("SC_PAGE_SIZE") / 1024**2 + + +def _metric_payload(root): + sta = root / "sta_ecc" + payload = {} + for pattern in ("*/*/qor_summary.json", "*/*/power_summary.json"): + for path in sorted((sta / "feature").glob(pattern)): + payload[str(path.relative_to(sta))] = json.loads(path.read_text()) + if not payload: + raise ValueError("STA corner artifacts are absent") + for stage in ("sta_ecc", "Harden_ecc"): + qor = json.loads((root / stage / "analysis/qor_metrics.json").read_text()) + payload[f"{stage}/metrics"] = { + item["id"]: item["value"] + for item in qor["metrics"] + if item["id"] not in {"runtime_seconds", "peak_memory_mb"} + } + checklist = json.loads((root / stage / "checklist.json").read_text()) + payload[f"{stage}/gates"] = {item["id"]: item["state"] for item in checklist["checklist"]} + metrics = payload["sta_ecc/metrics"] + expected = metrics.get("sta_expected_corner_count", 0) + qor_corners = {str(Path(key).parent) for key in payload if key.endswith("/qor_summary.json")} + power_corners = { + str(Path(key).parent) for key in payload if key.endswith("/power_summary.json") + } + if ( + type(expected) is not int + or expected <= 0 + or metrics.get("sta_corner_count") != expected + or metrics.get("sta_missing_corner_count") != 0 + or len(qor_corners) != expected + or qor_corners != power_corners + ): + raise ValueError("STA timing/power corner coverage is incomplete") + return payload + + +def _compare(reference, candidate, path=""): + if isinstance(reference, dict) and isinstance(candidate, dict): + if reference.keys() != candidate.keys(): + raise ValueError(f"metric keys differ at {path}") + for key in reference: + _compare(reference[key], candidate[key], f"{path}/{key}") + elif isinstance(reference, list) and isinstance(candidate, list): + if len(reference) != len(candidate): + raise ValueError(f"metric list length differs at {path}") + for index, (left, right) in enumerate(zip(reference, candidate, strict=True)): + _compare(left, right, f"{path}/{index}") + elif type(reference) is float and type(candidate) in (int, float): + if not math.isclose(reference, candidate, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError(f"metric differs at {path}: {reference} != {candidate}") + elif type(reference) is not type(candidate) or reference != candidate: + raise ValueError(f"metric differs at {path}: {reference} != {candidate}") + + +def _run_workspace(root): + from agent.engine import AgentEngineFlow + from agent.workspace_api import _prepare_candidate_rerun + from chipcompiler.data import StateEnum + from chipcompiler.data.workspace import load_workspace + + workspace = load_workspace(root) + flow = AgentEngineFlow(workspace) + flow.create_step_workspaces(initialize_config=False, executable_steps={"sta", "Harden"}) + _prepare_candidate_rerun( + workspace, flow, [flow.get_workspace_step(stage) for stage in ("sta", "Harden")] + ) + if not flow.init_db_engine(): + raise RuntimeError("cannot initialize routed benchmark database") + elapsed = {} + for stage in ("sta", "Harden"): + started = time.monotonic() + state = flow.run_step(stage, rerun=True) + elapsed[stage] = time.monotonic() - started + if state != StateEnum.Success: + raise RuntimeError(f"benchmark stage failed: {stage}: {state}") + payload = _metric_payload(root) + (root / "sta-benchmark-result.json").write_text( + json.dumps({"elapsed_seconds": elapsed, "metrics": payload}, indent=2) + "\n" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--workers", type=int, nargs="+", default=[1, 2, 4], choices=[1, 2, 4]) + parser.add_argument("--repeats", type=int, default=1) + parser.add_argument("--workspace", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args() + if args.workspace: + _run_workspace(args.workspace) + return + if not args.source or not args.output or args.repeats < 1 or args.workers[0] != 1: + parser.error( + "source, new output, positive repeats and a serial-first schedule are required" + ) + source, output = args.source.resolve(), args.output.resolve() + if output == source or source in output.parents or output in source.parents: + parser.error("output must be independent of the source workspace") + before = _inventory(source) + output.mkdir(parents=True, exist_ok=False) + (output / "source-inventory.json").write_text(json.dumps(before, indent=2) + "\n") + metadata = { + "command": sys.argv, + "python": sys.version, + "platform": platform.platform(), + "cpu_count": os.cpu_count(), + "packages": {name: importlib.metadata.version(name) for name in ("ecc", "ecc-tools-bin")}, + "implementation_sha256": { + name: hashlib.sha256(Path(__file__).with_name(name).read_bytes()).hexdigest() + for name in ("sta_parallel.py", "sta_benchmark.py", "engine.py", "tools.py") + }, + } + (output / "environment.json").write_text(json.dumps(metadata, indent=2) + "\n") + from agent.candidate_clone import candidate_clone_ignore + + results, reference = [], None + try: + for repeat in range(args.repeats): + for workers in args.workers: + name = f"r{repeat + 1}-w{workers}" + root = output / ".agent/candidates" / name + shutil.copytree(source, root, ignore=candidate_clone_ignore(source, "sta")) + env = dict(os.environ, ECOS_AGENT_STA_WORKERS=str(workers)) + started, peak = time.monotonic(), 0.0 + with (output / f"{name}.log").open("w") as log: + process = subprocess.Popen( + [sys.executable, "-m", "agent.sta_benchmark", "--workspace", str(root)], + env=env, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + while process.poll() is None: + peak = max(peak, _tree_rss_mb(process.pid)) + time.sleep(0.05) + if process.returncode: + raise RuntimeError(f"{name} failed; see {output / (name + '.log')}") + finally: + if process.poll() is None: + import signal + + os.killpg(process.pid, signal.SIGKILL) + process.wait() + result = json.loads((root / "sta-benchmark-result.json").read_text()) + if reference is None: + reference = result["metrics"] + _compare(reference, result["metrics"]) + results.append( + { + "run": name, + "workers": workers, + "elapsed_seconds": result["elapsed_seconds"], + "driver_seconds": time.monotonic() - started, + "sampled_peak_tree_rss_mb": peak, + "metrics_match_serial": True, + } + ) + print(json.dumps(results[-1]), flush=True) + (output / "results.json").write_text(json.dumps(results, indent=2) + "\n") + finally: + unchanged = before == _inventory(source) + (output / "source-unchanged.json").write_text(json.dumps({"unchanged": unchanged}) + "\n") + if not unchanged: + raise RuntimeError("source workspace changed during benchmark") + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/agent/sta_parallel.py b/agent/sta_parallel.py new file mode 100644 index 000000000..fda558d9b --- /dev/null +++ b/agent/sta_parallel.py @@ -0,0 +1,202 @@ +"""Candidate-only full-corner STA with isolated native processes.""" + +import multiprocessing +import os +import shutil +import signal +import sys +import time +from contextlib import suppress +from pathlib import Path +from tempfile import TemporaryDirectory + +from chipcompiler.engine.step_execution import get_process_rss_mb +from chipcompiler.runtime.operations import RuntimeFlowObserver, RuntimeOperationCancelled +from chipcompiler.tools.ecc import runner +from chipcompiler.tools.ecc.module import ECCToolsModule +from chipcompiler.tools.ecc.sta_artifacts import copy_sta_artifact, discard_sta_outputs +from chipcompiler.tools.ecc.sta_qor import sta_artifact_directory +from chipcompiler.utility.log import redirect_stdio_to_file + +from .plot import _is_candidate_workspace + + +def sta_workers(workspace, step) -> int: + if step.tool != "ecc" or step.name != "sta" or not _is_candidate_workspace(workspace): + return 1 + value = os.environ.get("ECOS_AGENT_STA_WORKERS", "2" if sys.platform == "linux" else "1") + if value not in {"1", "2", "4"}: + raise ValueError("ECOS_AGENT_STA_WORKERS must be 1, 2, or 4") + if value != "1" and sys.platform != "linux": + raise ValueError("parallel STA requires Linux; use ECOS_AGENT_STA_WORKERS=1") + return int(value) + + +def _arm_parent_death_signal(): + import ctypes + + # RPC close can kill the parent without running its Python cleanup. + parent_pid = multiprocessing.parent_process().pid + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(1, signal.SIGKILL, 0, 0, 0) != 0: # PR_SET_PDEATHSIG + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + if os.getppid() != parent_pid: + os.kill(os.getpid(), signal.SIGKILL) + + +def _run_corner(db_config, snapshot, job, log_path): + _arm_parent_death_signal() + redirect_stdio_to_file(str(log_path)) + module = ECCToolsModule() + try: + module.init_config(db_config, job["work_dir"], job["feature_dir"]) + if not module.load_data(snapshot): + raise RuntimeError("STA worker failed to load candidate database snapshot") + module.run_timing(**job) + finally: + module.close() + + +def track_sta_process_memory(pid, stop_event, peak_memory): + while True: + pending = [pid] + seen = set() + rss = 0.0 + while pending: + current = pending.pop() + if current in seen: + continue + seen.add(current) + rss += get_process_rss_mb(current) + for path in Path(f"/proc/{current}/task").glob("*/children"): + with suppress(OSError, ValueError): + pending.extend(int(value) for value in path.read_text().split()) + peak_memory[0] = max(peak_memory[0], rss) + if stop_event.wait(0.1): + return + + +def _check_cancelled(workspace): + observer = getattr(workspace, "_runtime_flow_observer", None) + if isinstance(observer, RuntimeFlowObserver): + status = observer._manager.operation_status(observer._operation_id) + if status["cancelRequested"]: + raise RuntimeOperationCancelled("candidate STA cancelled") + + +def _run_processes(jobs, workers, check_cancelled): + context = multiprocessing.get_context("spawn") + active = [] + pending = iter(jobs) + exhausted = False + try: + while active or not exhausted: + check_cancelled() + while len(active) < workers and not exhausted: + args = next(pending, None) + if args is None: + exhausted = True + break + process = context.Process(target=_run_corner, args=args) + process.start() + active.append((process, args[-1])) + for process, log_path in active[:]: + if process.exitcode is None: + continue + process.join() + active.remove((process, log_path)) + exitcode = process.exitcode + process.close() + if exitcode != 0: + raise RuntimeError(f"STA corner worker exited with {exitcode}; log: {log_path}") + if active: + time.sleep(0.05) + check_cancelled() + finally: + for process, _log_path in active: + if process.is_alive(): + process.terminate() + for process, _log_path in active: + process.join(timeout=5) + if process.is_alive(): + process.kill() + process.join() + process.close() + + +class _ParallelTiming: + def __init__(self, module, workspace, step, workers, count, root): + self.module = module + self.workspace = workspace + self.step = step + self.workers = workers + self.count = count + self.root = root + self.jobs = [] + + def __getattr__(self, name): + return getattr(self.module, name) + + def run_timing(self, **job): + # The last call is the barrier: generic run_sta cannot mark success early. + self.jobs.append(job) + if len(self.jobs) != self.count: + return + snapshot = self.root / "snapshot" + self.module.save_data(snapshot) + if not self.module.is_db_data_exists(snapshot): + raise RuntimeError("STA candidate database snapshot is incomplete") + tasks = [] + for index, original in enumerate(self.jobs): + root = self.root / str(index) + job = dict( + original, + work_dir=root / "work", + report_dir=root / "report", + feature_dir=root / "feature", + ) + for key in ("work_dir", "report_dir", "feature_dir"): + job[key].mkdir(parents=True) + log_path = Path(self.step.log.dir) / f"sta-corner-{index}.log" + tasks.append((self.workspace.config.get("db", ""), snapshot, job, log_path)) + _run_processes(tasks, self.workers, lambda: _check_cancelled(self.workspace)) + for original, (_, _, job, _) in zip(self.jobs, tasks, strict=True): + for key in ("report_dir", "feature_dir"): + for artifact in job[key].iterdir(): + if artifact.is_file(): + copy_sta_artifact(artifact, Path(original[key])) + + +def run_parallel_sta(workspace, step, ecc_module, workers): + items = runner.collect_sta_signoff_items(workspace) + destinations = [] + for item in items: + for root in (step.report.dir, step.feature.dir): + path = sta_artifact_directory( + root or "", item["corner"], item["temperature"], item["rcx_corner"] + ) + if path is not None: + destinations.append(path) + discard_sta_outputs(path) + # A failed rerun must not expose old aggregate metrics as current evidence. + analysis = Path(step.analysis.dir) + if analysis.is_dir(): + for path in analysis.iterdir(): + if path.is_file(): + path.unlink() + succeeded = False + try: + module = runner.get_eda_instance(workspace, step, ecc_module) + if module is None: + return False + with TemporaryDirectory(prefix="agent-sta-", dir=step.data.dir) as directory: + proxy = _ParallelTiming(module, workspace, step, workers, len(items), Path(directory)) + succeeded = runner.run_sta(workspace, step, proxy) + return succeeded + finally: + if not succeeded: + for path in destinations: + discard_sta_outputs(path) + if analysis.is_dir(): + shutil.rmtree(analysis) diff --git a/agent/test/test_sta_benchmark.py b/agent/test/test_sta_benchmark.py new file mode 100644 index 000000000..aca49a852 --- /dev/null +++ b/agent/test/test_sta_benchmark.py @@ -0,0 +1,58 @@ +import json + +import pytest + +from agent.sta_benchmark import _compare, _inventory, _metric_payload + + +def test_benchmark_comparison_preserves_counts_and_coverage(): + original = {"corner-a": {"wns": 1.0, "nvp": 2}, "power": [0.5]} + _compare(original, {"corner-a": {"wns": 1.0 + 1e-10, "nvp": 2}, "power": [0.5]}) + for changed in ( + {"corner-a": {"wns": 1.1, "nvp": 2}, "power": [0.5]}, + {"corner-a": {"wns": 1.0, "nvp": 3}, "power": [0.5]}, + {"corner-a": {"wns": float("nan"), "nvp": 2}, "power": [0.5]}, + {"power": [0.5]}, + ): + with pytest.raises(ValueError): + _compare(original, changed) + + +def test_benchmark_inventory_excludes_candidates_and_detects_change(tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "input").write_text("old") + (source / ".agent").mkdir() + (source / ".agent/ignored").write_text("ignored") + before = _inventory(source) + assert set(before) == {"input"} + (source / "input").write_text("new") + assert before != _inventory(source) + + +def test_benchmark_inventory_rejects_directory_symlinks(tmp_path): + (tmp_path / "link").symlink_to(tmp_path, target_is_directory=True) + with pytest.raises(ValueError, match="directory symlink"): + _inventory(tmp_path) + + +def test_benchmark_requires_complete_power_and_timing_coverage(tmp_path): + metrics = { + "sta_expected_corner_count": 1, + "sta_corner_count": 1, + "sta_missing_corner_count": 0, + } + for stage in ("sta_ecc", "Harden_ecc"): + root = tmp_path / stage + (root / "analysis").mkdir(parents=True) + (root / "analysis/qor_metrics.json").write_text( + json.dumps({"metrics": [{"id": key, "value": value} for key, value in metrics.items()]}) + ) + (root / "checklist.json").write_text('{"checklist": []}') + corner = tmp_path / "sta_ecc/feature/MAX/RCworst" + corner.mkdir(parents=True) + (corner / "qor_summary.json").write_text("{}") + with pytest.raises(ValueError, match="coverage is incomplete"): + _metric_payload(tmp_path) + (corner / "power_summary.json").write_text("{}") + assert _metric_payload(tmp_path)["sta_ecc/metrics"] == metrics diff --git a/agent/test/test_sta_parallel.py b/agent/test/test_sta_parallel.py new file mode 100644 index 000000000..e5e2c7231 --- /dev/null +++ b/agent/test/test_sta_parallel.py @@ -0,0 +1,285 @@ +import multiprocessing +import os +import signal +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent import sta_parallel as sta + + +def _guarded_worker(connection, stale_parent): + import ctypes + + if stale_parent: + sta.multiprocessing.parent_process = lambda: SimpleNamespace(pid=-1) + sta._arm_parent_death_signal() + value = ctypes.c_int() + assert ctypes.CDLL(None).prctl(2, ctypes.byref(value), 0, 0, 0) == 0 + connection.send(value.value) + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux parent-death signal") +@pytest.mark.parametrize("stale_parent", [False, True]) +def test_worker_arms_parent_death_signal_and_closes_startup_race(stale_parent): + context = multiprocessing.get_context("spawn") + receiver, sender = context.Pipe(duplex=False) + process = context.Process(target=_guarded_worker, args=(sender, stale_parent)) + try: + process.start() + process.join(timeout=15) + assert process.exitcode == (-signal.SIGKILL if stale_parent else 0) + if not stale_parent: + assert receiver.poll(1) + assert receiver.recv() == signal.SIGKILL + finally: + if process.is_alive(): + os.kill(process.pid, signal.SIGKILL) + process.join() + process.close() + receiver.close() + sender.close() + + +def _guarded_slow_worker(connection): + sta._arm_parent_death_signal() + connection.send(os.getpid()) + time.sleep(30) + + +def _guarded_parent(connection): + sta._arm_parent_death_signal() + worker = multiprocessing.get_context("spawn").Process( + target=_guarded_slow_worker, args=(connection,) + ) + worker.start() + worker.join() + + +def _parent_death_supervisor(connection, death_signal): + import ctypes + + # Adopt and reap the orphan in this isolated process, not in the test runner. + assert ctypes.CDLL(None).prctl(36, 1, 0, 0, 0) == 0 # PR_SET_CHILD_SUBREAPER + receiver, sender = multiprocessing.get_context("spawn").Pipe(duplex=False) + parent = multiprocessing.get_context("spawn").Process(target=_guarded_parent, args=(sender,)) + parent.start() + worker_pid = None + reaped = False + try: + assert receiver.poll(15) + worker_pid = receiver.recv() + os.kill(parent.pid, death_signal) + parent.join(timeout=5) + assert parent.exitcode == -death_signal + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pid, status = os.waitpid(worker_pid, os.WNOHANG) + if pid: + reaped = True + connection.send(os.waitstatus_to_exitcode(status)) + return + time.sleep(0.05) + raise AssertionError("STA worker survived parent termination") + finally: + if parent.is_alive(): + parent.kill() + parent.join() + if worker_pid is not None and not reaped: + os.kill(worker_pid, signal.SIGKILL) + os.waitpid(worker_pid, 0) + parent.close() + receiver.close() + sender.close() + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux parent-death signal") +@pytest.mark.parametrize("death_signal", [signal.SIGTERM, signal.SIGKILL]) +def test_terminating_rpc_parent_kills_corner_worker(death_signal): + context = multiprocessing.get_context("spawn") + receiver, sender = context.Pipe(duplex=False) + supervisor = context.Process(target=_parent_death_supervisor, args=(sender, death_signal)) + try: + supervisor.start() + supervisor.join(timeout=25) + assert supervisor.exitcode == 0 + assert receiver.poll(1) + assert receiver.recv() == -signal.SIGKILL + finally: + if supervisor.is_alive(): + supervisor.kill() + supervisor.join() + supervisor.close() + receiver.close() + sender.close() + + +def _workspace(tmp_path): + return SimpleNamespace( + directory=tmp_path / ".agent" / "candidates" / "one", config={"db": "db.json"} + ) + + +def test_worker_setting_is_candidate_sta_only(tmp_path, monkeypatch): + monkeypatch.setattr(sta.sys, "platform", "linux") + workspace = _workspace(tmp_path) + step = SimpleNamespace(tool="ecc", name="sta") + assert sta.sta_workers(workspace, step) == 2 + for value in ("1", "2", "4"): + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", value) + assert sta.sta_workers(workspace, step) == int(value) + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", "13") + with pytest.raises(ValueError, match="1, 2, or 4"): + sta.sta_workers(workspace, step) + step.name = "Harden" + assert sta.sta_workers(workspace, step) == 1 + + +def test_non_linux_candidates_keep_serial_default(tmp_path, monkeypatch): + monkeypatch.setattr(sta.sys, "platform", "darwin") + monkeypatch.delenv("ECOS_AGENT_STA_WORKERS", raising=False) + workspace = _workspace(tmp_path) + step = SimpleNamespace(tool="ecc", name="sta") + assert sta.sta_workers(workspace, step) == 1 + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", "2") + with pytest.raises(ValueError, match="requires Linux"): + sta.sta_workers(workspace, step) + step.name = "sta" + workspace.directory = tmp_path / "ordinary" + assert sta.sta_workers(workspace, step) == 1 + + +@pytest.mark.parametrize("fail", [False, True]) +def test_full_corner_barrier_isolates_jobs_and_publishes_only_after_success( + tmp_path, monkeypatch, fail +): + workspace = _workspace(tmp_path) + step = SimpleNamespace(log=SimpleNamespace(dir=tmp_path / "log")) + module = SimpleNamespace(save_data=lambda _: None, is_db_data_exists=lambda _: True) + proxy = sta._ParallelTiming(module, workspace, step, 2, 13, tmp_path / "temporary") + originals = [ + dict( + work_dir=tmp_path / "shared", + report_dir=tmp_path / "report" / str(index), + feature_dir=tmp_path / "feature" / str(index), + corner=str(index), + lib_paths=["lib"], + spef_path="spef", + sdc_path="sdc", + config="sta.json", + ) + for index in range(13) + ] + calls = [] + + def run(jobs, workers, check): + calls.append(jobs) + assert workers == 2 + assert len(jobs) == 13 + assert len({job[2]["work_dir"] for job in jobs}) == 13 + for original, (_, _, job, _) in zip(originals, jobs, strict=True): + assert job["corner"] == original["corner"] + assert job["lib_paths"] == original["lib_paths"] + assert not original["report_dir"].exists() + for key in ("report_dir", "feature_dir"): + (job[key] / "result.json").write_text(job["corner"]) + if fail: + raise RuntimeError("corner failed") + check() + + monkeypatch.setattr(sta, "_run_processes", run) + for job in originals[:-1]: + proxy.run_timing(**job) + assert not calls + if fail: + with pytest.raises(RuntimeError, match="corner failed"): + proxy.run_timing(**originals[-1]) + assert not (tmp_path / "report").exists() + else: + proxy.run_timing(**originals[-1]) + for job in originals: + assert (job["feature_dir"] / "result.json").read_text() == job["corner"] + assert len(calls) == 1 + + +def test_failed_validation_clears_stale_corner_and_aggregate(tmp_path, monkeypatch): + workspace = _workspace(tmp_path) + step = SimpleNamespace( + report=SimpleNamespace(dir=tmp_path / "report"), + feature=SimpleNamespace(dir=tmp_path / "feature"), + analysis=SimpleNamespace(dir=tmp_path / "analysis"), + data=SimpleNamespace(dir=tmp_path), + ) + item = dict(corner="MAX", temperature=125, rcx_corner="rcworst") + for root in (step.report.dir, step.feature.dir): + directory = sta.sta_artifact_directory(root, "MAX", 125, "rcworst") + directory.mkdir(parents=True) + (directory / "qor_summary.json").write_text("old") + step.analysis.dir.mkdir() + (step.analysis.dir / "qor_metrics.json").write_text("old") + monkeypatch.setattr(sta.runner, "collect_sta_signoff_items", lambda _: [item]) + monkeypatch.setattr(sta.runner, "get_eda_instance", lambda *_: object()) + monkeypatch.setattr(sta.runner, "run_sta", lambda *_: False) + assert sta.run_parallel_sta(workspace, step, None, 2) is False + assert not list(tmp_path.rglob("*.json")) + assert not list(tmp_path.glob("agent-sta-*")) + + +def _slow_worker(_db, _snapshot, _job, log_path): + Path(log_path).write_text("started") + time.sleep(30) + + +def _failed_worker(_db, _snapshot, _job, log_path): + if Path(log_path).stem == "0": + raise SystemExit(7) + time.sleep(30) + + +def test_worker_crash_reaps_remaining_processes(tmp_path, monkeypatch): + original_children = {child.pid for child in multiprocessing.active_children()} + monkeypatch.setattr(sta, "_run_corner", _failed_worker) + tasks = [(None, None, None, tmp_path / f"{index}.log") for index in range(4)] + with pytest.raises(RuntimeError, match="exited with 7"): + sta._run_processes(tasks, 2, lambda: None) + assert {child.pid for child in multiprocessing.active_children()} == original_children + + +def test_runtime_observer_cancellation_is_checked(): + manager = SimpleNamespace(operation_status=lambda _: {"cancelRequested": True}) + observer = sta.RuntimeFlowObserver(manager, "operation") + workspace = SimpleNamespace(_runtime_flow_observer=observer) + with pytest.raises(sta.RuntimeOperationCancelled): + sta._check_cancelled(workspace) + + +def test_spawn_cancellation_reaps_all_workers(tmp_path, monkeypatch): + original_children = {child.pid for child in multiprocessing.active_children()} + monkeypatch.setattr(sta, "_run_corner", _slow_worker) + tasks = [(None, None, None, tmp_path / f"{index}.log") for index in range(4)] + started = time.monotonic() + + def cancel(): + if len(list(tmp_path.glob("*.log"))) == 2: + raise sta.RuntimeOperationCancelled("cancelled") + assert time.monotonic() - started < 20 + + with pytest.raises(sta.RuntimeOperationCancelled): + sta._run_processes(tasks, 2, cancel) + assert len(list(tmp_path.glob("*.log"))) == 2 + assert {child.pid for child in multiprocessing.active_children()} == original_children + + +def test_memory_counts_simultaneous_descendant_rss_once(tmp_path, monkeypatch): + for pid, children in ((1, "2 3"), (2, "3"), (3, "")): + path = tmp_path / str(pid) / "task" / str(pid) / "children" + path.parent.mkdir(parents=True) + path.write_text(children) + monkeypatch.setattr(sta, "Path", lambda value: tmp_path / value.removeprefix("/proc/")) + monkeypatch.setattr(sta, "get_process_rss_mb", lambda pid: {1: 10, 2: 20, 3: 30}[pid]) + peak = [0] + sta.track_sta_process_memory(1, SimpleNamespace(wait=lambda _: True), peak) + assert peak == [60] diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index 3fbb02787..d59a4daa8 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -4,6 +4,8 @@ from pathlib import Path from types import SimpleNamespace +import pytest + from agent import tools as eda from agent.data import parameter_runtime_observer as runtime_observer from agent.data.candidate_materialization import materialize_candidate_config @@ -205,3 +207,26 @@ def run_step(workspace, step, ecc_module): assert eda.run_step(workspace, step, ecc_module=True) is True assert consumed == [16] + + +@pytest.mark.parametrize("workers", [1, 2, 4]) +def test_candidate_sta_routes_only_parallel_mode_to_agent(monkeypatch, tmp_path, workers): + calls = [] + workspace = SimpleNamespace(directory=tmp_path / ".agent" / "candidates" / "one", logger=None) + step = SimpleNamespace(name="sta", tool="ecc") + tool = SimpleNamespace( + build_step_config=lambda *_: None, + run_step=lambda **_: calls.append("serial") or True, + ) + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", str(workers)) + monkeypatch.setattr(eda, "load_eda_module", lambda *_args, **_kwargs: tool) + monkeypatch.setattr(eda, "log_workspace_step", lambda *_: None) + monkeypatch.setattr(eda, "reapply_materialized_candidate_config", lambda *_: None) + monkeypatch.setattr(eda, "run_with_parameter_observation", lambda *args: args[-1]()) + monkeypatch.setattr( + eda, + "run_parallel_sta", + lambda _workspace, _step, _module, count: calls.append(count) or True, + ) + assert eda.run_step(workspace, step) is True + assert calls == (["serial"] if workers == 1 else [workers]) diff --git a/agent/tools.py b/agent/tools.py index f34371a9c..5dd524d47 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -6,6 +6,7 @@ from .data.parameter_runtime_observer import run_with_parameter_observation from .plot import AgentECCToolsPlot from .runtime_env import isolated_sizer_loader_environment +from .sta_parallel import run_parallel_sta, sta_workers ecc_runner.ECCToolsPlot = AgentECCToolsPlot @@ -19,6 +20,9 @@ def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool log_workspace_step(step, workspace.logger) def run_tool(): + workers = sta_workers(workspace, step) + if workers > 1: + return run_parallel_sta(workspace, step, ecc_module, workers) if step.tool != "sizer": return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) with isolated_sizer_loader_environment(): From a4b3d02bbf2e26727e8900bc93fda125393132b2 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Mon, 7 Sep 2026 10:45:44 +0800 Subject: [PATCH 75/90] feat: add isolated floorplan mode switching --- agent/FLOORPLAN_MODE.md | 61 +++++ agent/candidate_resume.py | 37 ++- agent/floorplan_mode.py | 167 +++++++++++++ agent/requests.py | 2 + agent/test/test_floorplan_mode.py | 298 ++++++++++++++++++++++++ agent/test/test_floorplan_mode_rerun.py | 190 +++++++++++++++ agent/tools.py | 2 + agent/workspace_api.py | 62 +++-- 8 files changed, 791 insertions(+), 28 deletions(-) create mode 100644 agent/FLOORPLAN_MODE.md create mode 100644 agent/floorplan_mode.py create mode 100644 agent/test/test_floorplan_mode.py create mode 100644 agent/test/test_floorplan_mode_rerun.py diff --git a/agent/FLOORPLAN_MODE.md b/agent/FLOORPLAN_MODE.md new file mode 100644 index 000000000..c4b534fad --- /dev/null +++ b/agent/FLOORPLAN_MODE.md @@ -0,0 +1,61 @@ +# Isolated Floorplan Modes + +`candidate.rerun` accepts the optional `floorplanMode` (`floorplan_mode`) field: + +- `die_util`: derive core geometry from utilization and aspect ratio. +- `die_size`: use the candidate's existing fixed width and height, validated as + finite positive numbers. This does not introduce a new dimension-setting API. +- Omitted: preserve ordinary behavior, or inherit an isolated parent's mode. + +An explicit mode requires `targetStep: "Floorplan"`, `endStep: "Harden"`, and +`executionScope: "full_flow"`. The existing context hashes, seed, unique candidate +ID and idempotency key remain required. Unknown modes and mode changes starting +after Floorplan are rejected before an operation starts. + +Use `patch: []` with an explicit mode to run an isolated baseline without changing +a parameter. Then use its successful `candidateRootRef` as +`parentCandidateRootRef` for a one-knob candidate. For example, the mode-specific +fields for baseline preparation are: + +```json +{ + "targetStep": "Floorplan", + "endStep": "Harden", + "executionScope": "full_flow", + "floorplanMode": "die_util", + "patch": [] +} +``` + +For a subsequent parameter experiment, omit `floorplanMode` to inherit it and +provide the usual single-knob patch, such as +`[{"knob_id":"floorplan.core_util","value":0.7}]`. An explicit `die_size` on +a new Floorplan candidate switches back without changing its parent. Mode-only +baselines have input-binding and mode evidence, not a fabricated parameter +application receipt. + +## Isolation And Evidence + +All implementation lives in `agent/`. The native builder, parameters, algorithms +and ordinary workspace behavior are unchanged. The override runs after native +step-config rebuilding, before native execution. Fixed `die.size` remains in +the isolated parameters so switching back is possible; it does not override +the explicit `die_util` selection at the Agent execution boundary. + +`analysis/floorplan_mode.v1.json` binds the candidate, mode, previous mode, fixed +dimensions when applicable, patch, context hashes, seed and source config hash. +The candidate manifest binds that file, and the candidate state hash covers it +and the canonical `home/params.toml`. Workspaces without a mode receipt keep +their existing state-hash contract. +Success additionally requires the final config to retain the selected mode. +`candidate.resume` reuses the recorded mode and rejects context or artifact +drift; changing modes requires a new candidate, not a resume override. + +The ordinary ECC CLI does not apply this Agent-owned override. Use the Agent +rerun/resume path for these candidates. Existing ECOS GUI callers do not select +a new mode automatically; the caller must explicitly request the baseline. + +Compare candidates against a baseline executed in the same mode. Switching a +mode and a knob together cannot isolate the knob's effect. Config/receipt tests +prove execution wiring, not QoR improvement or signoff. Historical fixed-size +baselines and their experiment denominators are not rewritten. diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py index 4bd313a3c..74b94169c 100644 --- a/agent/candidate_resume.py +++ b/agent/candidate_resume.py @@ -11,11 +11,13 @@ from chipcompiler.utility.path import path_is_within from .data.candidate_artifacts import validate_candidate_id +from .data.candidate_input_binding import reapply_candidate_input_binding from .data.candidate_materialization import ( candidate_written_patch, reapply_materialized_candidate_config, validate_candidate_materialization_receipt, ) +from .floorplan_mode import FLOORPLAN_MODE_REF, validate_floorplan_mode_resume from .requests import CandidateRerunRequest, CandidateResumeRequest from .workspace_api import ( _CANDIDATE_WORKSPACE_MANIFEST, @@ -229,9 +231,12 @@ def _validate_candidate_resume_manifest( def _validate_candidate_resume_artifacts(candidate_root: Path, artifacts: object) -> None: required = { - "candidate_materialization": "analysis/candidate_materialization.v1.json", "candidate_input_binding": "analysis/candidate_input_binding.v1.json", } + if isinstance(artifacts, dict) and "floorplan_mode" in artifacts: + required["floorplan_mode"] = FLOORPLAN_MODE_REF + else: + required["candidate_materialization"] = "analysis/candidate_materialization.v1.json" if not isinstance(artifacts, dict) or any( not isinstance(artifacts.get(key), dict) or artifacts[key].get("ref") != ref for key, ref in required.items() @@ -302,11 +307,22 @@ def _validated_candidate_resume_patch( ) -> list[dict]: target_step = manifest["target_step"] try: - reapply_materialized_candidate_config(workspace, target_step) - materialization = validate_candidate_materialization_receipt(workspace, target_step) - if materialization is None or materialization["candidate_id"] != request.candidate_id: - raise ValueError("candidate materialization receipt is missing or mismatched") - _reapply_candidate_input(workspace, flow, target_step) + mode = validate_floorplan_mode_resume(workspace, request) + if mode is not None and mode["target_step"] != target_step: + raise ValueError("candidate resume floorplan mode stage is invalid") + mode_only = mode is not None and mode["patch"] == [] + if mode_only: + if target_step != "Floorplan" or "candidate_materialization" in manifest["artifacts"]: + raise ValueError("mode-only baseline binding is invalid") + binding = reapply_candidate_input_binding(workspace, flow, target_step) + if binding is None or binding["candidate_id"] != request.candidate_id: + raise ValueError("mode-only baseline input binding is invalid") + else: + reapply_materialized_candidate_config(workspace, target_step) + materialization = validate_candidate_materialization_receipt(workspace, target_step) + if materialization is None or materialization["candidate_id"] != request.candidate_id: + raise ValueError("candidate materialization receipt is missing or mismatched") + _reapply_candidate_input(workspace, flow, target_step) except ValueError as exc: raise RuntimeApiError( "command_failed", f"candidate resume receipt binding is invalid: {exc}" @@ -318,9 +334,13 @@ def _validated_candidate_resume_patch( raise RuntimeApiError("command_failed", "candidate resume seed binding is invalid") from exc if not isinstance(dreamplace, dict) or dreamplace.get("random_seed") != request.seed: raise RuntimeApiError("command_failed", "candidate resume seed binding is invalid") + if mode_only: + return [] requested_patch = _candidate_resume_requested_patch( workspace, manifest, request, materialization["patch"], target_step ) + if mode is not None and mode["patch"] != requested_patch: + raise RuntimeApiError("command_failed", "candidate resume floorplan mode patch is invalid") try: written_patch = candidate_written_patch(workspace, target_step, requested_patch) except ValueError as exc: @@ -368,6 +388,7 @@ def _candidate_resume_requested_patch( def _candidate_resume_config_backups(workspace) -> dict[Path, bytes]: root = Path(workspace.directory) relatives = ( + "home/params.toml", "home/parameters.json", "config/floorplan_ecc.json", "config/cts_ecc.json", @@ -389,7 +410,9 @@ def _restore_candidate_resume_configs(workspace, backups: dict[Path, bytes]) -> parameters = getattr(workspace, "parameters", None) parameters_path = getattr(parameters, "path", None) if parameters_path and Path(parameters_path) in backups: - parameters.data = json.loads(backups[Path(parameters_path)]) + from chipcompiler.data.parameter import load_parameter + + parameters.data = load_parameter(parameters_path).data def _notify_candidate_resume_prepared(observer, steps: list, target_step: str) -> None: diff --git a/agent/floorplan_mode.py b/agent/floorplan_mode.py new file mode 100644 index 000000000..26fdeff9c --- /dev/null +++ b/agent/floorplan_mode.py @@ -0,0 +1,167 @@ +"""Explicit, persisted floorplan mode overrides for isolated Agent candidates.""" + +import math +import re +from pathlib import Path + +from chipcompiler.runtime.workspace_api import RuntimeApiError + +from .data.candidate_artifacts import ( + canonical_json_bytes, + read_json_object, + sha256_bytes, + sha256_path, + write_json_atomic, +) + +FLOORPLAN_MODE_REF = "analysis/floorplan_mode.v1.json" +_SCHEMA = "ecc.agent.floorplan_mode.v1" +_MODES = ("die_util", "die_size") + + +def validate_floorplan_mode_request(request) -> None: + mode = request.floorplan_mode + if mode is not None and (mode not in _MODES or request.target_step != "Floorplan"): + raise RuntimeApiError( + "invalid_request", "floorplan_mode must be die_util or die_size at Floorplan" + ) + + +def _local_path(workspace, path) -> Path: + root = Path(workspace.directory).absolute() + path = Path(path).absolute() + if root.resolve() != root or path.resolve() != path or not path.is_relative_to(root): + raise ValueError("floorplan mode path is unsafe") + return path + + +def _isolated_root(workspace) -> Path: + root = _local_path(workspace, workspace.directory) + if root.parent.name != "candidates" or root.parent.parent.name != ".agent": + raise ValueError("floorplan mode requires an isolated candidate workspace") + return root + + +def _positive_number(value) -> bool: + return type(value) in (int, float) and math.isfinite(value) and value > 0 + + +def _validate_size(size) -> None: + if ( + not isinstance(size, dict) + or set(size) != {"width_micron", "height_micron"} + or not all(_positive_number(value) for value in size.values()) + ): + raise ValueError("die_size requires finite positive width and height") + + +def read_floorplan_mode(workspace, *, inherited=False) -> dict | None: + path = _local_path(workspace, Path(workspace.directory) / FLOORPLAN_MODE_REF) + if not path.exists(): + return None + root = _isolated_root(workspace) + receipt = read_json_object(path, "floorplan mode receipt") + payload = {key: value for key, value in receipt.items() if key != "receipt_sha256"} + if receipt.get("receipt_sha256") != sha256_bytes(canonical_json_bytes(payload)): + raise ValueError("floorplan mode receipt hash is invalid") + if ( + receipt.get("schema") != _SCHEMA + or receipt.get("mode") not in _MODES + or receipt.get("previous_mode") not in _MODES + or (not inherited and receipt.get("candidate_id") != root.name) + or type(receipt.get("seed")) is not int + or not isinstance(receipt.get("target_step"), str) + or not isinstance(receipt.get("patch"), list) + or len(receipt["patch"]) > 1 + ): + raise ValueError("floorplan mode receipt binding is invalid") + for field in ("context_sha256", "parameter_card_sha256", "source_config_sha256"): + if not isinstance(receipt.get(field), str) or not re.fullmatch( + r"sha256:[0-9a-f]{64}", receipt[field] + ): + raise ValueError("floorplan mode receipt context is invalid") + if receipt["mode"] == "die_size": + _validate_size(receipt.get("die_size")) + elif receipt.get("die_size") is not None: + raise ValueError("die_util cannot bind a fixed die size") + return receipt + + +def prepare_floorplan_mode(workspace, request) -> None: + inherited = read_floorplan_mode(workspace, inherited=True) + if request.floorplan_mode is None and inherited is None: + return + root = _isolated_root(workspace) + if root.name != request.candidate_id: + raise ValueError("floorplan mode candidate identity is invalid") + config_path = _local_path(workspace, workspace.config["Floorplan"]) + config = read_json_object(config_path, "floorplan config") + builder = config.get("die_builder") + if not isinstance(builder, dict) or builder.get("mode") not in _MODES: + raise ValueError("floorplan die_builder mode is invalid") + mode = request.floorplan_mode if request.floorplan_mode is not None else inherited["mode"] + if mode not in _MODES: + raise ValueError("floorplan mode is invalid") + size = None + if mode == "die_size": + size = ( + inherited["die_size"] + if inherited and inherited["mode"] == mode + else builder.get("die_size") + ) + _validate_size(size) + receipt = { + "schema": _SCHEMA, + "candidate_id": request.candidate_id, + "target_step": request.target_step, + "mode": mode, + "previous_mode": inherited["mode"] if inherited else builder["mode"], + "die_size": size, + "patch": request.patch, + "context_sha256": request.context_sha256, + "parameter_card_sha256": request.parameter_card_sha256, + "seed": request.seed, + "source_config_sha256": sha256_path(config_path), + } + receipt["receipt_sha256"] = sha256_bytes(canonical_json_bytes(receipt)) + write_json_atomic(root / FLOORPLAN_MODE_REF, receipt) + apply_floorplan_mode(workspace, "Floorplan") + + +def apply_floorplan_mode(workspace, step_name: str) -> None: + if step_name != "Floorplan": + return + receipt = read_floorplan_mode(workspace) + if receipt is None: + return + path = _local_path(workspace, workspace.config["Floorplan"]) + config = read_json_object(path, "floorplan config") + builder = config.get("die_builder") + if not isinstance(builder, dict): + raise ValueError("floorplan die_builder is invalid") + builder["mode"] = receipt["mode"] + if receipt["mode"] == "die_size": + builder["die_size"] = receipt["die_size"] + write_json_atomic(path, config) + + +def validate_floorplan_mode_result(workspace, terminal_state: str) -> None: + receipt = read_floorplan_mode(workspace) + if receipt is None or terminal_state != "succeeded": + return + path = _local_path(workspace, workspace.config["Floorplan"]) + builder = read_json_object(path, "floorplan config").get("die_builder", {}) + if builder.get("mode") != receipt["mode"] or ( + receipt["mode"] == "die_size" and builder.get("die_size") != receipt["die_size"] + ): + raise ValueError("terminal floorplan mode does not match the isolated request") + + +def validate_floorplan_mode_resume(workspace, request) -> dict | None: + receipt = read_floorplan_mode(workspace) + if receipt is not None and any( + receipt[field] != getattr(request, field) + for field in ("candidate_id", "context_sha256", "parameter_card_sha256", "seed") + ): + raise ValueError("candidate resume floorplan mode context is invalid") + return receipt diff --git a/agent/requests.py b/agent/requests.py index 5efa0d9b0..4780011d9 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -43,6 +43,7 @@ class CandidateRerunRequest: parameter_card_sha256: str seed: int parent_candidate_root_ref: str | None = None + floorplan_mode: str | None = None @dataclass(frozen=True) @@ -66,6 +67,7 @@ class CandidateResumeRequest: "contextSha256": "context_sha256", "parameterCardSha256": "parameter_card_sha256", "parentCandidateRootRef": "parent_candidate_root_ref", + "floorplanMode": "floorplan_mode", } diff --git a/agent/test/test_floorplan_mode.py b/agent/test/test_floorplan_mode.py new file mode 100644 index 000000000..0e267a82a --- /dev/null +++ b/agent/test/test_floorplan_mode.py @@ -0,0 +1,298 @@ +import json +import shutil +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from agent.floorplan_mode import ( + FLOORPLAN_MODE_REF, + apply_floorplan_mode, + prepare_floorplan_mode, + read_floorplan_mode, +) +from agent.requests import CandidateRerunRequest, parse_agent_request_model +from agent.workspace_api import _validate_candidate_rerun_request +from chipcompiler.runtime.workspace_api import RuntimeApiError + + +def _request(**kwargs): + return CandidateRerunRequest( + **{ + "workspace_id": "workspace-1", + "candidate_id": "candidate-1", + "target_step": "Floorplan", + "end_step": "Harden", + "execution_scope": "full_flow", + "idempotency_key": "mode-1", + "context_sha256": "sha256:" + "a" * 64, + "parameter_card_sha256": "sha256:" + "b" * 64, + "seed": 17, + "patch": [], + "floorplan_mode": "die_util", + **kwargs, + } + ) + + +def _workspace(tmp_path): + root = tmp_path / ".agent" / "candidates" / "candidate-1" + config = root / "config" / "floorplan_ecc.json" + config.parent.mkdir(parents=True) + config.write_text( + json.dumps( + { + "die_builder": { + "mode": "die_size", + "die_size": {"width_micron": 100, "height_micron": 200}, + "die_util": {"utilization": 0.5, "aspect_ratio": 1.0}, + } + } + ) + ) + return SimpleNamespace(directory=root, config={"Floorplan": config}) + + +@pytest.mark.parametrize("mode", ("die_util", "die_size")) +def test_explicit_mode_only_baseline_request(mode): + request = _request(floorplan_mode=mode) + _validate_candidate_rerun_request(request) + payload = dict(vars(request)) + payload["floorplanMode"] = payload.pop("floorplan_mode") + assert parse_agent_request_model(CandidateRerunRequest, payload) == request + + +@pytest.mark.parametrize( + "overrides", + ( + {"floorplan_mode": "auto"}, + {"floorplan_mode": False}, + {"floorplan_mode": None}, + {"target_step": "place"}, + ), +) +def test_invalid_mode_request_is_rejected_before_execution(overrides): + with pytest.raises(RuntimeApiError): + _validate_candidate_rerun_request(_request(**overrides)) + + +@pytest.mark.parametrize("mode", ("die_util", "die_size")) +def test_mode_is_reapplied_after_config_rebuild_and_reload(tmp_path, mode): + workspace = _workspace(tmp_path) + prepare_floorplan_mode(workspace, _request(floorplan_mode=mode)) + config = workspace.config["Floorplan"] + rebuilt = json.loads(config.read_text()) + rebuilt["die_builder"]["mode"] = "die_size" if mode == "die_util" else "die_util" + config.write_text(json.dumps(rebuilt)) + reloaded = SimpleNamespace(directory=workspace.directory, config=workspace.config) + apply_floorplan_mode(reloaded, "Floorplan") + assert json.loads(config.read_text())["die_builder"] == { + "mode": mode, + "die_size": {"width_micron": 100, "height_micron": 200}, + "die_util": {"utilization": 0.5, "aspect_ratio": 1.0}, + } + receipt = read_floorplan_mode(reloaded) + assert receipt["mode"] == mode + assert receipt["previous_mode"] == "die_size" + + +def test_mode_tampering_and_nonisolated_workspace_fail_closed(tmp_path): + workspace = _workspace(tmp_path) + prepare_floorplan_mode(workspace, _request()) + path = workspace.directory / FLOORPLAN_MODE_REF + receipt = json.loads(path.read_text()) + receipt["mode"] = "die_size" + path.write_text(json.dumps(receipt)) + with pytest.raises(ValueError, match="hash"): + apply_floorplan_mode(workspace, "Floorplan") + ordinary = SimpleNamespace(directory=tmp_path, config=workspace.config) + with pytest.raises(ValueError, match="isolated"): + prepare_floorplan_mode(ordinary, _request()) + + +def test_missing_mode_leaves_ordinary_workspace_unchanged(tmp_path): + ordinary = SimpleNamespace(directory=tmp_path, config={}) + prepare_floorplan_mode(ordinary, replace(_request(), floorplan_mode=None)) + apply_floorplan_mode(ordinary, "Floorplan") + assert list(tmp_path.iterdir()) == [] + + +def test_mode_state_hash_binds_canonical_parameters_and_resume_rolls_back(tmp_path): + from agent.candidate_resume import ( + _candidate_resume_config_backups, + _restore_candidate_resume_configs, + ) + from agent.workspace_api import _workspace_state_sha256 + from chipcompiler.data.parameter import load_parameter + + workspace = _workspace(tmp_path) + home = workspace.directory / "home" + home.mkdir() + params = home / "params.toml" + params.write_text("[params.core]\nutilitization = 0.4\n") + workspace.parameters = load_parameter(params) + prepare_floorplan_mode(workspace, _request()) + digest = _workspace_state_sha256(workspace.directory) + backups = _candidate_resume_config_backups(workspace) + params.write_text("[params.core]\nutilitization = 0.7\n") + assert _workspace_state_sha256(workspace.directory) != digest + _restore_candidate_resume_configs(workspace, backups) + assert _workspace_state_sha256(workspace.directory) == digest + assert workspace.parameters.data["core"]["utilitization"] == 0.4 + + +def test_terminal_success_rejects_a_lost_mode_override(tmp_path): + from agent.floorplan_mode import validate_floorplan_mode_result + + workspace = _workspace(tmp_path) + prepare_floorplan_mode(workspace, _request()) + config = workspace.config["Floorplan"] + payload = json.loads(config.read_text()) + payload["die_builder"]["mode"] = "die_size" + config.write_text(json.dumps(payload)) + with pytest.raises(ValueError, match="terminal floorplan mode"): + validate_floorplan_mode_result(workspace, "succeeded") + validate_floorplan_mode_result(workspace, "failed") + + +def test_mode_inheritance_switchback_and_context_binding(tmp_path): + from agent.floorplan_mode import validate_floorplan_mode_resume + + first = _workspace(tmp_path) + prepare_floorplan_mode(first, _request()) + before = { + p.relative_to(first.directory): p.read_bytes() + for p in first.directory.rglob("*") + if p.is_file() + } + second_root = first.directory.with_name("candidate-2") + shutil.copytree(first.directory, second_root) + second = SimpleNamespace( + directory=second_root, config={"Floorplan": second_root / "config/floorplan_ecc.json"} + ) + request = _request( + candidate_id="candidate-2", + floorplan_mode=None, + patch=[{"knob_id": "floorplan.core_util", "value": 0.7}], + ) + prepare_floorplan_mode(second, request) + assert read_floorplan_mode(second)["mode"] == "die_util" + assert read_floorplan_mode(second)["candidate_id"] == "candidate-2" + assert validate_floorplan_mode_resume(second, request)["patch"] == request.patch + with pytest.raises(ValueError, match="context"): + validate_floorplan_mode_resume(second, replace(request, seed=18)) + prepare_floorplan_mode(second, replace(request, floorplan_mode="die_size")) + assert json.loads(second.config["Floorplan"].read_text())["die_builder"]["mode"] == "die_size" + assert before == { + p.relative_to(first.directory): p.read_bytes() + for p in first.directory.rglob("*") + if p.is_file() + } + + +@pytest.mark.parametrize("width", (0, -1, False, float("nan"), float("inf"))) +def test_fixed_size_rejects_invalid_dimensions_without_writing_receipt(tmp_path, width): + workspace = _workspace(tmp_path) + config = workspace.config["Floorplan"] + payload = json.loads(config.read_text()) + payload["die_builder"]["die_size"]["width_micron"] = width + config.write_text(json.dumps(payload)) + before = config.read_bytes() + with pytest.raises(ValueError, match="positive"): + prepare_floorplan_mode(workspace, _request(floorplan_mode="die_size")) + assert config.read_bytes() == before + assert not (workspace.directory / FLOORPLAN_MODE_REF).exists() + + +def test_mode_rejects_config_symlinks(tmp_path): + workspace = _workspace(tmp_path) + config = workspace.config["Floorplan"] + outside = tmp_path / "outside.json" + config.rename(outside) + config.symlink_to(outside) + before = outside.read_bytes() + with pytest.raises(ValueError, match="unsafe"): + prepare_floorplan_mode(workspace, _request()) + assert outside.read_bytes() == before + + +@pytest.mark.parametrize("mode", ("die_util", "die_size")) +@pytest.mark.parametrize( + "knob,value,field", + ( + ("floorplan.core_util", 0.7, "utilization"), + ("floorplan.aspect_ratio", 2.0, "aspect_ratio"), + ), +) +def test_parameter_patch_survives_native_refresh_in_selected_mode( + tmp_path, monkeypatch, mode, knob, value, field +): + from agent import tools + from agent.data.candidate_materialization import materialize_candidate_config + from agent.data.floorplan_parameter_observer import build_floorplan_report + from agent.test.data.test_candidate_materialization import _workspace as parameter_workspace + from chipcompiler.data.parameter import load_parameter, save_parameter + from chipcompiler.data.workspace import _refresh_floorplan_config + + root = tmp_path / ".agent/candidates/candidate-1" + workspace = parameter_workspace(root) + workspace.pdk.tap_cell = "" + workspace.pdk.end_cap = "" + workspace.parameters = load_parameter(workspace.parameters.path) + workspace.parameters.data["die"] = {"size": [100, 200]} + save_parameter(workspace.parameters) + workspace.logger = SimpleNamespace() + _refresh_floorplan_config(workspace) + patch = [{"knob_id": knob, "value": value}] + prepare_floorplan_mode(workspace, _request(floorplan_mode=mode, patch=patch)) + materialize_candidate_config(workspace, "Floorplan", patch, "candidate-1") + observed = [] + + def builder(workspace, _step): + workspace.parameters = load_parameter(workspace.parameters.path) + _refresh_floorplan_config(workspace) + assert ( + json.loads(workspace.config["Floorplan"].read_text())["die_builder"]["mode"] + == "die_size" + ) + + def native(**_kwargs): + observed.append(json.loads(workspace.config["Floorplan"].read_text())["die_builder"]) + return True + + monkeypatch.setattr( + tools, + "load_eda_module", + lambda *_args, **_kwargs: SimpleNamespace(build_step_config=builder, run_step=native), + ) + monkeypatch.setattr(tools, "log_workspace_step", lambda *_args: None) + monkeypatch.setattr( + tools, "run_with_parameter_observation", lambda _ws, _step, _mat, run: run() + ) + assert tools.run_step(workspace, SimpleNamespace(name="Floorplan", tool="ecc")) + assert observed[0]["mode"] == mode + assert observed[0]["die_util"][field] == value + assert workspace.parameters.data["die"]["size"] == [100, 200] + feature = root / "feature.json" + feature.write_text( + json.dumps( + { + "Design Layout": { + "core_usage": 0.69, + "core_bounding_width": 40, + "core_bounding_height": 20, + } + } + ) + ) + report = build_floorplan_report( + patch[0], + { + "init_fp_call_count": 1, + "run_fp_call_count": 1, + "config_path": str(workspace.config["Floorplan"]), + }, + feature, + engine_succeeded=True, + ) + assert report["activation"]["status"] == ("used" if mode == "die_util" else "not_activated") diff --git a/agent/test/test_floorplan_mode_rerun.py b/agent/test/test_floorplan_mode_rerun.py new file mode 100644 index 000000000..5d5831cc9 --- /dev/null +++ b/agent/test/test_floorplan_mode_rerun.py @@ -0,0 +1,190 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +from agent import tools +from agent.floorplan_mode import FLOORPLAN_MODE_REF, read_floorplan_mode +from agent.requests import CandidateResumeRequest +from agent.test.test_floorplan_mode import _request +from agent.test.test_workspace_api import _EccApi, _Flow, _wait_for_terminal +from agent.workspace_api import FlowAgentRuntimeApi +from chipcompiler.data import StateEnum + + +def _api(tmp_path, monkeypatch, *, fail_once=False): + home = tmp_path / "home" + home.mkdir() + flow_path = home / "flow.json" + data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "Floorplan", "tool": "ecc", "state": "Success"}, + {"name": "Harden", "tool": "ecc", "state": "Success"}, + ] + } + flow_path.write_text(json.dumps(data)) + config = tmp_path / "config" + config.mkdir() + (config / "dreamplace.json").write_text('{"random_seed": 0}') + (config / "floorplan_ecc.json").write_text( + json.dumps( + { + "die_builder": { + "mode": "die_size", + "die_size": {"width_micron": 100, "height_micron": 200}, + "die_util": {"utilization": 0.5, "aspect_ratio": 1}, + } + } + ) + ) + netlist = tmp_path / "Synthesis_yosys/output/synth.v" + netlist.parent.mkdir(parents=True) + netlist.write_text("module gcd(); endmodule\n") + workspace = SimpleNamespace(directory=tmp_path, flow=SimpleNamespace(data=data, path=flow_path)) + consumed = [] + + class Api(_EccApi): + def _load_workspace(self, directory): + candidate = super()._load_workspace(directory) + candidate.config["Floorplan"] = Path(directory) / "config/floorplan_ecc.json" + candidate.logger = SimpleNamespace() + return candidate + + class Flow(_Flow): + def get_workspace_step(self, name): + return next(step for step in self.workspace_steps if step.name == name) + + def run_step(self, step, *, rerun, observer=None): + if step.name == "Floorplan": + success = tools.run_step(self.workspace, step) + state = StateEnum.Success if success else StateEnum.Incomplete + self.get_step(step.name, step.tool)["state"] = state.value + self.save() + return state + result = super().run_step(step, rerun=rerun, observer=observer) + self.get_step(step.name, step.tool)["state"] = "Success" + self.save() + return result + + def build_flow(candidate, **_kwargs): + root = Path(candidate.directory) + return Flow( + candidate, + ( + SimpleNamespace( + name="Synthesis", + tool="yosys", + output={"verilog": root / "Synthesis_yosys/output/synth.v"}, + ), + SimpleNamespace( + name="Floorplan", + tool="ecc", + input=SimpleNamespace(), + output={"dir": root / "Floorplan_ecc/output"}, + ), + SimpleNamespace( + name="Harden", + tool="ecc", + output=SimpleNamespace( + dir=root / "Harden_ecc/output", + gds=root / "Harden_ecc/output/gcd_Harden.gds", + lef=root / "Harden_ecc/output/gcd_Harden.lef", + lib=root / "Harden_ecc/output/gcd_Harden.lib", + ), + ), + ), + ) + + def rebuild(candidate, _step): + path = candidate.config["Floorplan"] + config = json.loads(path.read_text()) + config["die_builder"]["mode"] = "die_size" + path.write_text(json.dumps(config)) + + def native(*, workspace, **_kwargs): + consumed.append( + json.loads(workspace.config["Floorplan"].read_text())["die_builder"]["mode"] + ) + return not (fail_once and len(consumed) == 1) + + api = FlowAgentRuntimeApi(Api(workspace)) + monkeypatch.setattr(api, "_build_flow", build_flow) + monkeypatch.setattr( + "agent.workspace_api._init_db_engine_for_workspace_step", lambda *_args: None + ) + monkeypatch.setattr( + tools, + "load_eda_module", + lambda *_args, **_kwargs: SimpleNamespace(build_step_config=rebuild, run_step=native), + ) + monkeypatch.setattr(tools, "log_workspace_step", lambda *_args: None) + return api, consumed + + +def test_mode_only_baseline_clones_switches_and_preserves_source(tmp_path, monkeypatch): + api, consumed = _api(tmp_path, monkeypatch) + before = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + operation = api.candidate_rerun(_request()) + terminal = _wait_for_terminal(api.ecc_api.operations, operation["operationId"]) + assert consumed == ["die_util"] + result = terminal["result"] + assert "parameterApplicationReceipt" not in result + first = tmp_path / result["candidateRootRef"] + manifest = json.loads((first / "analysis/candidate_workspace.v1.json").read_text()) + assert manifest["artifacts"]["floorplan_mode"]["ref"] == FLOORPLAN_MODE_REF + assert "candidate_materialization" not in manifest["artifacts"] + assert before == {ref: (tmp_path / ref).read_bytes() for ref in before} + operation = api.candidate_rerun( + _request( + candidate_id="candidate-2", + idempotency_key="mode-2", + floorplan_mode="die_size", + parent_candidate_root_ref=result["candidateRootRef"], + ) + ) + _wait_for_terminal(api.ecc_api.operations, operation["operationId"]) + assert consumed == ["die_util", "die_size"] + assert read_floorplan_mode(api.ecc_api._load_workspace(first))["mode"] == "die_util" + + +def test_failed_mode_baseline_resumes_without_changing_mode(tmp_path, monkeypatch): + api, consumed = _api(tmp_path, monkeypatch, fail_once=True) + request = _request() + started = api.candidate_rerun(request) + _wait_for_terminal(api.ecc_api.operations, started["operationId"], expected_state="failed") + resumed = api.candidate_resume( + CandidateResumeRequest( + workspace_id=request.workspace_id, + candidate_id=request.candidate_id, + idempotency_key="resume-1", + context_sha256=request.context_sha256, + parameter_card_sha256=request.parameter_card_sha256, + seed=request.seed, + ) + ) + terminal = _wait_for_terminal(api.ecc_api.operations, resumed["operationId"]) + assert terminal["result"]["resumeStep"] == "Floorplan" + assert consumed == ["die_util", "die_util"] + + +def test_resume_rejects_mode_receipt_tampering_before_running(tmp_path, monkeypatch): + api, consumed = _api(tmp_path, monkeypatch, fail_once=True) + request = _request() + started = api.candidate_rerun(request) + _wait_for_terminal(api.ecc_api.operations, started["operationId"], expected_state="failed") + receipt = tmp_path / ".agent/candidates/candidate-1" / FLOORPLAN_MODE_REF + payload = json.loads(receipt.read_text()) + payload["mode"] = "die_size" + receipt.write_text(json.dumps(payload)) + resumed = api.candidate_resume( + CandidateResumeRequest( + workspace_id=request.workspace_id, + candidate_id=request.candidate_id, + idempotency_key="resume-tampered", + context_sha256=request.context_sha256, + parameter_card_sha256=request.parameter_card_sha256, + seed=request.seed, + ) + ) + _wait_for_terminal(api.ecc_api.operations, resumed["operationId"], expected_state="failed") + assert consumed == ["die_util"] diff --git a/agent/tools.py b/agent/tools.py index 5dd524d47..5d7092500 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -4,6 +4,7 @@ from .data import reapply_materialized_candidate_config from .data.parameter_runtime_observer import run_with_parameter_observation +from .floorplan_mode import apply_floorplan_mode from .plot import AgentECCToolsPlot from .runtime_env import isolated_sizer_loader_environment from .sta_parallel import run_parallel_sta, sta_workers @@ -17,6 +18,7 @@ def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool return False eda_module.build_step_config(workspace, step) materialization = reapply_materialized_candidate_config(workspace, step.name) + apply_floorplan_mode(workspace, step.name) log_workspace_step(step, workspace.logger) def run_tool(): diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 4fef11003..8fd2c5542 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -33,6 +33,12 @@ ) from .data.parameter_application_receipt import build_parameter_application_receipt from .engine import AgentEngineFlow +from .floorplan_mode import ( + FLOORPLAN_MODE_REF, + prepare_floorplan_mode, + validate_floorplan_mode_request, + validate_floorplan_mode_result, +) from .requests import ( CandidateBindInputRequest, CandidateMaterializeRequest, @@ -184,12 +190,14 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> ) if not preflight_done: _preflight_candidate_steps(steps) - if request.patch: - _materialize_candidate_rerun(candidate_workspace, flow, request) + prepare_floorplan_mode(candidate_workspace, request) + _materialize_candidate_rerun(candidate_workspace, flow, request) _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_rerun_prepared(observer, steps, request) if request.patch: _reapply_candidate_input(candidate_workspace, flow, request.target_step) + else: + reapply_candidate_input_binding(candidate_workspace, flow, request.target_step) for step in steps: _run_candidate_step(flow, step, observer=observer) return _candidate_rerun_result( @@ -316,6 +324,7 @@ def _step_value(step, field: str): def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: + validate_floorplan_mode_request(request) for name in ("workspace_id", "target_step", "end_step", "candidate_id"): value = getattr(request, name) if not isinstance(value, str) or not value.strip(): @@ -330,19 +339,20 @@ def _validate_candidate_rerun_request(request: CandidateRerunRequest) -> None: raise RuntimeApiError( "invalid_request", "candidate rerun execution scope must be full_flow" ) - if not isinstance(request.patch, list) or len(request.patch) != 1: + mode_only = request.floorplan_mode is not None and request.patch == [] + if not isinstance(request.patch, list) or (len(request.patch) != 1 and not mode_only): raise RuntimeApiError("invalid_request", "candidate rerun requires exactly one patch item") - patch_item = request.patch[0] - if not isinstance(patch_item, dict) or set(patch_item) != {"knob_id", "value"}: - raise RuntimeApiError( - "invalid_request", "candidate rerun patch item must contain only knob_id and value" - ) - if not isinstance(patch_item["knob_id"], str) or not patch_item["knob_id"]: - raise RuntimeApiError("invalid_request", "candidate rerun knob_id is invalid") - try: - json.dumps(patch_item["value"], allow_nan=False) - except (TypeError, ValueError) as exc: - raise RuntimeApiError("invalid_request", "candidate rerun value is not JSON") from exc + for patch_item in request.patch: + if not isinstance(patch_item, dict) or set(patch_item) != {"knob_id", "value"}: + raise RuntimeApiError( + "invalid_request", "candidate rerun patch item must contain only knob_id and value" + ) + if not isinstance(patch_item["knob_id"], str) or not patch_item["knob_id"]: + raise RuntimeApiError("invalid_request", "candidate rerun knob_id is invalid") + try: + json.dumps(patch_item["value"], allow_nan=False) + except (TypeError, ValueError) as exc: + raise RuntimeApiError("invalid_request", "candidate rerun value is not JSON") from exc if not isinstance(request.idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch( request.idempotency_key ): @@ -419,6 +429,7 @@ def _create_candidate_workspace( def _workspace_state_sha256(root: Path) -> str: relative_files = ( + FLOORPLAN_MODE_REF, "home/flow.json", "home/parameters.json", "config/floorplan_ecc.json", @@ -426,6 +437,8 @@ def _workspace_state_sha256(root: Path) -> str: "config/dreamplace_ecc.json", "config/dreamplace.json", ) + if (root / FLOORPLAN_MODE_REF).is_file(): + relative_files += ("home/params.toml",) hashes = { relative: _required_file_sha256(root / relative, relative) for relative in relative_files @@ -560,6 +573,7 @@ def _candidate_workspace_receipt( execution_scope: str, terminal_state: str, ) -> dict: + validate_floorplan_mode_result(workspace, terminal_state) candidate_root = Path(workspace.directory).resolve() manifest_path = candidate_root / "analysis" / _CANDIDATE_WORKSPACE_MANIFEST if manifest_path.parent.is_symlink(): @@ -585,6 +599,7 @@ def _candidate_workspace_receipt( } artifacts = {} for key, relative in ( + ("floorplan_mode", FLOORPLAN_MODE_REF), ("candidate_materialization", "analysis/candidate_materialization.v1.json"), ("candidate_input_binding", "analysis/candidate_input_binding.v1.json"), ("parameter_runtime_report", "analysis/parameter_runtime_report.v1.json"), @@ -896,17 +911,22 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest raise RuntimeApiError("command_failed", "candidate DREAMPlace config is invalid") dreamplace_config["random_seed"] = request.seed write_json_atomic(dreamplace_path, dreamplace_config) - materialize_candidate_config( - workspace, - request.target_step, - request.patch, - request.candidate_id, - ) + if request.patch: + materialize_candidate_config( + workspace, + request.target_step, + request.patch, + request.candidate_id, + ) def _remove_stale_parameter_receipts(workspace_root: Path) -> None: analysis = workspace_root / "analysis" - for name in ("parameter_runtime_report.v1.json", "parameter_application_receipt.v1.json"): + for name in ( + "parameter_runtime_report.v1.json", + "parameter_application_receipt.v1.json", + "candidate_materialization.v1.json", + ): path = analysis / name if path.is_symlink(): raise RuntimeApiError("command_failed", "candidate parameter receipt path is unsafe") From f3d88b0a73b35827b9cebf1de68a01b27f2debe8 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Mon, 7 Sep 2026 15:32:04 +0800 Subject: [PATCH 76/90] refactor: simplify seven-knob runtime parameter status --- agent/data/candidate_materialization.py | 4 +- agent/data/floorplan_parameter_observer.py | 273 ++------ agent/data/parameter_application_receipt.py | 42 +- agent/data/parameter_runtime_observer.py | 654 +++++------------- agent/test/test_floorplan_mode.py | 4 +- .../test/test_parameter_receipt_artifacts.py | 114 ++- agent/test/test_parameter_runtime_observer.py | 463 ++++--------- agent/test/test_parameter_status.py | 26 + agent/test/test_tools.py | 10 +- agent/test/test_workspace_api.py | 46 +- agent/workspace_api.py | 56 +- 11 files changed, 498 insertions(+), 1194 deletions(-) create mode 100644 agent/test/test_parameter_status.py diff --git a/agent/data/candidate_materialization.py b/agent/data/candidate_materialization.py index ecc810fc5..90994e23e 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -135,7 +135,7 @@ def candidate_written_patch( target_step: str, patch: Any, ) -> list[dict[str, Any]]: - """Validate a surface patch and return the values written by L1.""" + """Validate a surface patch and return the materialized values.""" return _prepare_patch(workspace, target_step, patch)[0] @@ -529,7 +529,7 @@ def validate_candidate_materialization_receipt( workspace: Any, target_step: str, ) -> dict[str, Any] | None: - """Read and strictly bind an immutable L1 receipt to the current workspace.""" + """Read and strictly bind an immutable materialization receipt to the workspace.""" receipt_path = _receipt_path(workspace) if not receipt_path.exists(): return None diff --git a/agent/data/floorplan_parameter_observer.py b/agent/data/floorplan_parameter_observer.py index 77c42c0cb..ec1e98c2f 100644 --- a/agent/data/floorplan_parameter_observer.py +++ b/agent/data/floorplan_parameter_observer.py @@ -1,264 +1,113 @@ -"""Agent-owned floorplan boundary and realized-geometry observation.""" +"""Agent-owned observation of the two controlled floorplan parameters.""" import json import math -from collections.abc import Iterator from contextlib import ExitStack, contextmanager -from functools import partial, wraps +from functools import partial from pathlib import Path -from threading import RLock, get_ident -from typing import Any +from threading import RLock -from .candidate_artifacts import canonical_json_bytes, sha256_bytes, sha256_path +from .candidate_artifacts import sha256_path +from .parameter_runtime_observer import _patch_method -FLOORPLAN_OBSERVER_REVISION = "ecc.agent.floorplan_parameter_observer.v1" +FLOORPLAN_OBSERVER_REVISION = "ecc.agent.floorplan_parameter_observer.v2" FLOORPLAN_KNOBS = frozenset({"floorplan.core_util", "floorplan.aspect_ratio"}) -RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" -_MISSING = object() - -# ponytail: serialize same-process observers; use permanent thread-local hooks -# if parallel flow throughput matters. +# ponytail: serialize same-process observers; use thread-local hooks if throughput matters. _OBSERVATION_LOCK = RLock() @contextmanager -def capture_floorplan(patch: dict[str, Any]) -> Iterator[dict[str, Any]]: +def capture_floorplan(patch): from chipcompiler.tools.ecc.module import ECCToolsModule - boundary = { - "init_fp_call_count": 0, - "run_fp_call_count": 0, - } + boundary = {"init_fp_call_count": 0, "run_fp_call_count": 0, "run_fp_completed": False} with _OBSERVATION_LOCK, ExitStack() as stack: - _patch_method( - stack, - ECCToolsModule, - "init_fp", - partial(_observe_floorplan_init, boundary), - ) - _patch_method( - stack, - ECCToolsModule, - "run_fp", - partial(_observe_floorplan_run, boundary), - ) + _patch_method(stack, ECCToolsModule, "init_fp", partial(_observe_floorplan_init, boundary)) + _patch_method(stack, ECCToolsModule, "run_fp", partial(_observe_floorplan_run, boundary)) yield boundary -def _patch_method(stack, owner, name, observer) -> None: - original = getattr(owner, name) - owner_thread = get_ident() - - @wraps(original) - def observed(*args, **kwargs): - if get_ident() != owner_thread: - return original(*args, **kwargs) - return observer(original, *args, **kwargs) - - previous = vars(owner).get(name, _MISSING) - setattr(owner, name, observed) - stack.callback(_restore_attribute, owner, name, previous) - - -def _restore_attribute(owner, name, previous) -> None: - if previous is _MISSING: - delattr(owner, name) - else: - setattr(owner, name, previous) - - def _observe_floorplan_init(boundary, original, module, *args, **kwargs): config = kwargs.get("config", args[0] if args else None) + result = original(module, *args, **kwargs) boundary["init_fp_call_count"] += 1 boundary["config_path"] = str(config) if config else None - return original(module, *args, **kwargs) + return result def _observe_floorplan_run(boundary, original, module, *args, **kwargs): boundary["run_fp_call_count"] += 1 - return original(module, *args, **kwargs) + result = original(module, *args, **kwargs) + boundary["run_fp_completed"] = result is not False + return result -def build_floorplan_report( - patch: dict[str, Any], - boundary: dict[str, Any], - feature_path: str | Path | None, - *, - engine_succeeded: bool, -) -> dict[str, Any]: +def build_floorplan_report(patch, boundary, feature_path, *, engine_succeeded): knob_id = patch["knob_id"] - config_path = Path(boundary["config_path"]) if boundary.get("config_path") else None - config = _read_json(config_path) - die_builder = config.get("die_builder", {}) if config else {} - die_util = die_builder.get("die_util", {}) if isinstance(die_builder, dict) else {} - field_name, consumer_id = { - "floorplan.core_util": ("utilization", "ifp.die_builder.die_utilization"), - "floorplan.aspect_ratio": ( - "aspect_ratio", - "ifp.die_builder.die_aspect_ratio", - ), - }[knob_id] - configured = _scalar_value(die_util.get(field_name)) - geometry = _floorplan_geometry(feature_path) - realized = geometry.get( - "core_utilization" if knob_id == "floorplan.core_util" else "aspect_ratio" - ) - boundary_complete = ( - boundary.get("init_fp_call_count") == 1 - and boundary.get("run_fp_call_count") == 1 - and configured == patch["value"] - ) - mode_active = die_builder.get("mode") == "die_util" - used = engine_succeeded and boundary_complete and mode_active and realized is not None - not_activated = engine_succeeded and boundary_complete and not mode_active - status = "used" if used else "not_activated" if not_activated else "unknown" - observation = _floorplan_observation( - configured, - realized, - geometry, - boundary, - config_path, - die_builder.get("mode"), - mode_active=mode_active, - realized_available=used, - evidence_complete=used or not_activated, + config = _read_json(boundary.get("config_path")) + die_builder = config.get("die_builder", {}) + die_util = die_builder.get("die_util", {}) + field = "utilization" if knob_id == "floorplan.core_util" else "aspect_ratio" + configured = _scalar_value(die_util.get(field)) + feature = _read_json(feature_path).get("Design Layout", {}) + width = _scalar_value(feature.get("core_bounding_width")) + height = _scalar_value(feature.get("core_bounding_height")) + geometry = ( + boundary.get("run_fp_completed", False) + and width is not None + and width > 0 + and height is not None + and height > 0 ) - outcome = "geometry_constructed" if used else "evaluated" - evidence = _consumer_evidence(consumer_id, outcome, observation) + observation = { + "mode": die_builder.get("mode"), + "configured_value": configured, + "init_fp_call_count": boundary.get("init_fp_call_count", 0), + "run_fp_call_count": boundary.get("run_fp_call_count", 0), + "geometry_constructed": geometry, + } + actual, status, reason = None, "unknown", "Required floorplan observation is unavailable." + if ( + observation["init_fp_call_count"] == 1 + and observation["run_fp_call_count"] == 1 + and boundary.get("run_fp_completed", False) + ): + if observation["mode"] == "die_size": + status, reason = "inactive", "Fixed die dimensions do not use this parameter." + elif observation["mode"] == "die_util" and geometry and configured is not None: + actual, status, reason = configured, "effective", None return { + "schema_version": "tool.parameter_runtime_report.v2", "knob_id": knob_id, - "requested_value": patch["value"], + "written_value": patch["value"], "tool": { "name": "ECC-Floorplan", "revision": FLOORPLAN_OBSERVER_REVISION, "source_sha256": sha256_path(Path(__file__)), }, - "application_status": ("applied" if engine_succeeded and boundary_complete else "unknown"), - "effective_initial": {"value": configured, "unit": "ratio"}, - "effective_final": {"value": realized if used else None, "unit": "ratio"}, - "activation": { - "status": status, - "consumers": [evidence] if status in {"used", "not_activated"} else [], - }, - "transitions": _floorplan_transitions(configured, realized, evidence) if used else [], - "consumer_observation": observation, + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, } -def _floorplan_observation( - configured, - realized, - geometry, - boundary, - config_path, - mode, - *, - mode_active, - realized_available, - evidence_complete, -) -> dict[str, Any]: - lifecycle = [("adopted", configured, "ratio", "agent_python_boundary")] - if mode_active: - lifecycle.append(("consumed", configured, "ratio", "native_call_boundary")) - if realized_available: - lifecycle.append(("realized", realized, "ratio", "derived_verified_artifact")) - return { - "evidence_kind": "boundary_and_derived_output", - "configured_value": configured, - "mode": mode, - "init_fp_call_count": boundary.get("init_fp_call_count", 0), - "run_fp_call_count": boundary.get("run_fp_call_count", 0), - "config_sha256": sha256_path(config_path) if config_path else None, - "core_geometry": geometry.get("core_geometry"), - "realized_core_utilization": geometry.get("core_utilization"), - "realized_aspect_ratio": geometry.get("aspect_ratio"), - "evidence_complete": evidence_complete, - "lifecycle": _lifecycle(*lifecycle), - } - - -def _consumer_evidence(consumer_id, outcome, observation) -> dict[str, Any]: - payload = { - "consumer_id": consumer_id, - "outcome": outcome, - "consumer_observation": observation, - } - return { - "consumer_id": consumer_id, - "outcome": outcome, - "evidence_ref": RUNTIME_REPORT_REF, - "evidence_sha256": sha256_bytes(canonical_json_bytes(payload)), - } - - -def _floorplan_transitions(configured, realized, evidence) -> list[dict[str, Any]]: - if configured == realized: - return [] - return [ - { - "sequence": 0, - "from": "adopted", - "to": "adjusted", - "value": realized, - "reason": "Floorplan geometry realization", - "evidence_ref": RUNTIME_REPORT_REF, - "evidence_sha256": evidence["evidence_sha256"], - } - ] - - -def _floorplan_geometry(feature_path: str | Path | None) -> dict[str, Any]: - feature = _read_json(Path(feature_path)) if feature_path else None - layout = feature.get("Design Layout", {}) if feature else {} - width = _scalar_value(layout.get("core_bounding_width")) - height = _scalar_value(layout.get("core_bounding_height")) - if width is None or height is None or width <= 0 or height <= 0: +def _read_json(path): + if path is None: return {} - ratio = width / height - return { - "core_utilization": _scalar_value(layout.get("core_usage")), - "aspect_ratio": ratio, - "core_geometry": { - "width": {"value": width, "unit": "um"}, - "height": {"value": height, "unit": "um"}, - "area": { - "value": _scalar_value(layout.get("core_area")), - "unit": "um^2", - }, - "aspect_ratio": {"value": ratio, "unit": "ratio"}, - }, - } - - -def _read_json(path: Path | None) -> dict[str, Any] | None: - if path is None or not path.is_file(): - return None try: - value = json.loads(path.read_text(encoding="utf-8")) + value = json.loads(Path(path).read_text(encoding="utf-8")) except (OSError, ValueError): - return None - return value if isinstance(value, dict) else None + return {} + return value if isinstance(value, dict) else {} -def step_path(step: Any, group: str, name: str) -> str | Path | None: +def step_path(step, group, name): value = getattr(step, group, None) return value.get(name) if isinstance(value, dict) else getattr(value, name, None) -def _scalar_value(value: Any): +def _scalar_value(value): if type(value) is int: return value return value if type(value) is float and math.isfinite(value) else None - - -def _lifecycle(*events: tuple[str, Any, str, str]) -> list[dict[str, Any]]: - return [ - { - "sequence": sequence, - "phase": phase, - "value": value, - "unit": unit, - "evidence_kind": evidence_kind, - } - for sequence, (phase, value, unit, evidence_kind) in enumerate(events) - ] diff --git a/agent/data/parameter_application_receipt.py b/agent/data/parameter_application_receipt.py index e06a5d849..231d6137c 100644 --- a/agent/data/parameter_application_receipt.py +++ b/agent/data/parameter_application_receipt.py @@ -5,10 +5,9 @@ frozen JSON envelope. """ -from __future__ import annotations - import hashlib import json +import math import os from collections.abc import Mapping from pathlib import Path @@ -33,11 +32,6 @@ def build_parameter_application_receipt( """Aggregate native runtime facts and optionally atomically write the receipt.""" if not receipt_id or not requested.get("knob_id"): raise ValueError("receipt identity is required") - activation = runtime_report.get("activation") - if not isinstance(activation, Mapping): - raise ValueError("native activation facts are required") - if activation.get("status") == "used" and not activation.get("consumers"): - raise ValueError("used activation requires consumer evidence") normalized_tool = dict(tool) required_tool = ("name", "revision", "source_sha256") if any( @@ -49,27 +43,37 @@ def build_parameter_application_receipt( raise ValueError("bound tool metadata is not allowed") if not _is_sha256(normalized_tool["source_sha256"]): raise ValueError("tool source_sha256 is invalid") + if runtime_report.get("schema_version") != "tool.parameter_runtime_report.v2": + raise ValueError("runtime report v2 is required") + status = runtime_report.get("status") + actual = runtime_report.get("actual_value") + if status not in {"effective", "inactive", "unknown"}: + raise ValueError("parameter status is invalid") + if actual is not None and ( + type(actual) not in {bool, int, float} + or (type(actual) is float and not math.isfinite(actual)) + ): + raise ValueError("actual parameter value is invalid") + if status == "effective" and actual is None: + raise ValueError("effective parameter requires an actual value") + reason = runtime_report.get("reason") + observation = runtime_report.get("observation") + if (reason is not None and not isinstance(reason, str)) or not isinstance(observation, dict): + raise ValueError("parameter observation is invalid") normalized_materialization = dict(materialization) normalized_materialization.setdefault("parent_ref", None) payload: dict[str, Any] = { - "schema_version": "tool.parameter_application_receipt.v1", + "schema_version": "tool.parameter_application_receipt.v2", "receipt_id": receipt_id, "tool": normalized_tool, "context": dict(context), "requested": dict(requested), "materialization": normalized_materialization, - "effective_initial": runtime_report.get( - "effective_initial", {"value": None, "unit": requested.get("unit", "")} - ), - "transitions": list(runtime_report.get("transitions", [])), - "application_status": runtime_report.get("application_status", "unknown"), - "activation": dict(activation), - "effective_final": runtime_report.get( - "effective_final", {"value": None, "unit": requested.get("unit", "")} - ), + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, } - if "consumer_observation" in runtime_report: - payload["consumer_observation"] = runtime_report["consumer_observation"] payload["evidence_sha256"] = _sha256(payload) if destination is not None: destination = Path(destination) diff --git a/agent/data/parameter_runtime_observer.py b/agent/data/parameter_runtime_observer.py index 3a0e288d3..e375c0f90 100644 --- a/agent/data/parameter_runtime_observer.py +++ b/agent/data/parameter_runtime_observer.py @@ -1,4 +1,4 @@ -"""Agent-owned DREAMPlace runtime observation for controlled candidates.""" +"""Agent-owned observation of the five controlled DREAMPlace parameters.""" import math from collections.abc import Callable, Iterator @@ -9,21 +9,11 @@ from threading import RLock, get_ident from typing import Any -from .candidate_artifacts import ( - canonical_json_bytes, - sha256_bytes, - sha256_path, - write_json_atomic, -) +from .candidate_artifacts import sha256_path, write_json_atomic from .observed_callable import ObservedCallable -DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v1" -RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v1.json" - -# ponytail: serialize same-process observers; use permanent thread-local hooks -# if parallel flow throughput matters. -_OBSERVATION_LOCK = RLock() - +DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v2" +RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v2.json" DREAMPLACE_KNOBS = frozenset( { "place.target_density", @@ -33,6 +23,9 @@ "place.density_weight", } ) +# ponytail: serialize same-process observers; use thread-local hooks if throughput matters. +_OBSERVATION_LOCK = RLock() +_MISSING = object() @dataclass @@ -41,39 +34,22 @@ class DreamplaceRecorder: engine: Any = None model: Any = None ppa: dict[str, Any] = field(default_factory=dict) - placement_depth: int = 0 probe: dict[str, Any] = field( default_factory=lambda: { "density_operator_call_count": 0, - "density_weight_initializations": [], - "density_weight_updates": [], - "nonlinear_place_call_count": 0, + "initialization_count": 0, "place_object_count": 0, "routability_branch_round_count": 0, - "routability_operator_constructed": False, - "stop_overflow_read_count": 0, + "placement_completed": False, } ) -def run_with_parameter_observation( - workspace: Any, - step: Any, - materialization: dict[str, Any] | None, - invoke: Callable[[], Any], -) -> Any: - """Run a candidate step and persist runtime evidence without changing its result.""" +def run_with_parameter_observation(workspace, step, materialization, invoke): if materialization is None: return invoke() patch = materialization["patch"][0] - knob_id = patch["knob_id"] - if knob_id not in DREAMPLACE_KNOBS: - from .floorplan_parameter_observer import FLOORPLAN_KNOBS - - if knob_id not in FLOORPLAN_KNOBS: - return invoke() - - if knob_id in DREAMPLACE_KNOBS: + if patch["knob_id"] in DREAMPLACE_KNOBS: with _capture_dreamplace(patch) as recorder: return _invoke_and_record( workspace, @@ -82,17 +58,19 @@ def run_with_parameter_observation( patch, recorder.engine, recorder.ppa, - _final_probe(recorder), + recorder.probe, engine_succeeded=succeeded, ), ) - from .floorplan_parameter_observer import ( + FLOORPLAN_KNOBS, build_floorplan_report, capture_floorplan, step_path, ) + if patch["knob_id"] not in FLOORPLAN_KNOBS: + return invoke() with capture_floorplan(patch) as boundary: return _invoke_and_record( workspace, @@ -106,11 +84,7 @@ def run_with_parameter_observation( ) -def _invoke_and_record( - workspace: Any, - invoke: Callable[[], Any], - build_report: Callable[[bool], dict[str, Any]], -) -> Any: +def _invoke_and_record(workspace, invoke, build_report): try: result = invoke() except BaseException: @@ -120,15 +94,11 @@ def _invoke_and_record( return result -def _persist_report( - workspace: Any, - build_report: Callable[[bool], dict[str, Any]], - *, - engine_succeeded: bool, -) -> None: +def _persist_report(workspace, build_report, *, engine_succeeded): try: - report = build_report(engine_succeeded) - write_json_atomic(Path(workspace.directory) / RUNTIME_REPORT_REF, report) + write_json_atomic( + Path(workspace.directory) / RUNTIME_REPORT_REF, build_report(engine_succeeded) + ) except Exception: logger = getattr(workspace, "logger", None) if logger is not None: @@ -136,41 +106,18 @@ def _persist_report( @contextmanager -def _capture_dreamplace( - patch: dict[str, Any], -) -> Iterator[DreamplaceRecorder]: +def _capture_dreamplace(patch: dict[str, Any]) -> Iterator[DreamplaceRecorder]: from dreamplace.macroPlaceDB import MacroPlaceDB - from dreamplace.Params import Params from dreamplace.PlaceObj import PlaceObj from dreamplace.Placer import PlacementEngine recorder = DreamplaceRecorder(patch=patch) with _OBSERVATION_LOCK, ExitStack() as stack: - _patch_method( - stack, - PlacementEngine, - "run", - partial(_observe_placement_run, recorder), - ) - _patch_method( - stack, - PlacementEngine, - "place", - partial(_observe_placement_call, recorder), - ) - if patch["knob_id"] == "place.target_overflow": - _patch_method( - stack, - Params, - "__getattribute__", - partial(_observe_parameter_read, recorder), - ) + _patch_method(stack, PlacementEngine, "run", partial(_observe_placement_run, recorder)) + _patch_method(stack, PlacementEngine, "place", partial(_observe_placement_call, recorder)) if patch["knob_id"] == "place.cell_padding_x": _patch_method( - stack, - MacroPlaceDB, - "_apply_cell_padding", - partial(_observe_cell_padding, recorder), + stack, MacroPlaceDB, "_apply_cell_padding", partial(_observe_cell_padding, recorder) ) if patch["knob_id"] in { "place.target_density", @@ -178,20 +125,12 @@ def _capture_dreamplace( "place.routability_opt", }: _patch_method( - stack, - PlaceObj, - "__init__", - partial(_observe_place_object_init, recorder, stack), + stack, PlaceObj, "__init__", partial(_observe_place_object_init, recorder, stack) ) yield recorder -def _patch_method( - stack: ExitStack, - owner: Any, - name: str, - observer: Callable[..., Any], -) -> None: +def _patch_method(stack: ExitStack, owner: Any, name: str, observer: Callable) -> None: original = getattr(owner, name) owner_thread = get_ident() @@ -207,10 +146,7 @@ def observed(*args, **kwargs): stack.callback(_restore_attribute, owner, name, previous) -_MISSING = object() - - -def _restore_attribute(owner: Any, name: str, previous: Any) -> None: +def _restore_attribute(owner, name, previous): if previous is _MISSING: delattr(owner, name) else: @@ -218,50 +154,43 @@ def _restore_attribute(owner: Any, name: str, previous: Any) -> None: def _observe_placement_run(recorder, original, engine, *args, **kwargs): - try: - result = original(engine, *args, **kwargs) - finally: - recorder.engine = engine + recorder.engine = engine + result = original(engine, *args, **kwargs) if isinstance(result, dict): recorder.ppa = dict(result) return result def _observe_placement_call(recorder, original, engine, *args, **kwargs): - recorder.probe["nonlinear_place_call_count"] += 1 - recorder.placement_depth += 1 - try: - return original(engine, *args, **kwargs) - finally: - recorder.placement_depth -= 1 - - -def _observe_parameter_read(recorder, original, params, name): - value = original(params, name) - if recorder.placement_depth and name == "stop_overflow": - recorder.probe["stop_overflow_read_count"] += 1 - return value + recorder.engine = engine + placedb = getattr(engine, "placedb", None) + area = _scalar_value(getattr(placedb, "total_movable_node_area", None)) + space = _scalar_value(getattr(placedb, "total_space_area", None)) + if area is not None and space is not None and space > 0: + recorder.probe["utilization_floor"] = min(area / space + 0.05, 1.0) + result = original(engine, *args, **kwargs) + recorder.probe["placement_completed"] = True + metrics = getattr(engine, "metrics", None) + overflows = metrics.get("overflow", []) if isinstance(metrics, dict) else [] + if overflows: + recorder.ppa["overflow"] = _scalar_value(overflows[-1]) + return result def _observe_cell_padding(recorder, original, placedb, params, *args, **kwargs): - normalized = _scalar_value(getattr(params, "cell_padding_x", None)) result = original(placedb, params, *args, **kwargs) + padding = _scalar_value(getattr(placedb, "cell_padding_x", None)) + site = _scalar_value(getattr(placedb, "site_width", None)) recorder.probe["cell_padding"] = { - "normalized_padding_dbu": normalized, - "effective_padding_dbu": _scalar_value(getattr(placedb, "cell_padding_x", None)), + "padding_sites": padding / site + if padding is not None and site is not None and site > 0 + else None, "geometry_apply_count": 1, } return result -def _observe_place_object_init( - recorder, - stack, - original, - model, - *args, - **kwargs, -): +def _observe_place_object_init(recorder, stack, original, model, *args, **kwargs): result = original(model, *args, **kwargs) recorder.model = model recorder.probe["place_object_count"] += 1 @@ -269,22 +198,14 @@ def _observe_place_object_init( return result -def _observe_native_model( - model: Any, - recorder: DreamplaceRecorder, - stack: ExitStack, -) -> None: - knob_id = recorder.patch["knob_id"] +def _observe_native_model(model, recorder, stack): operations = model.op_collections + knob_id = recorder.patch["knob_id"] if knob_id == "place.target_density": for name in ("density_op", "fence_region_density_merged_op"): - operation = getattr(operations, name, None) - if callable(operation): + if callable(getattr(operations, name, None)): _patch_method( - stack, - operations, - name, - partial(_observe_density_operator, recorder), + stack, operations, name, partial(_observe_density_operator, recorder, model) ) elif knob_id == "place.density_weight": _patch_method( @@ -293,403 +214,144 @@ def _observe_native_model( "initialize_density_weight", partial(_observe_density_weight_initialization, recorder), ) - if callable(getattr(operations, "update_density_weight_op", None)): - _patch_method( - stack, - operations, - "update_density_weight_op", - partial(_observe_density_weight_update, recorder, model), - ) - elif knob_id == "place.routability_opt": - adjust_area = getattr(operations, "adjust_node_area_op", None) - recorder.probe["routability_operator_constructed"] = callable(adjust_area) - if callable(adjust_area): - _patch_method( - stack, - operations, - "adjust_node_area_op", - partial(_observe_routability_round, recorder), - ) + elif knob_id == "place.routability_opt" and callable( + getattr(operations, "adjust_node_area_op", None) + ): + _patch_method( + stack, operations, "adjust_node_area_op", partial(_observe_routability_round, recorder) + ) -def _observe_density_operator(recorder, original, *args, **kwargs): +def _observe_density_operator(recorder, model, original, *args, **kwargs): + result = original(*args, **kwargs) recorder.probe["density_operator_call_count"] += 1 - return original(*args, **kwargs) + recorder.probe["target_density"] = _scalar_value( + getattr(getattr(recorder.engine, "params", None), "target_density", None) + ) + recorder.probe["density_tensor_value"] = _scalar_value( + getattr(getattr(model, "data_collections", None), "target_density", None) + ) + return result def _observe_density_weight_initialization(recorder, original, *args, **kwargs): + params = args[0] if args else kwargs.get("params") + coefficient = _scalar_value(getattr(params, "density_weight", None)) result = original(*args, **kwargs) - if (value := _native_value(result)) is not None: - recorder.probe["density_weight_initializations"].append(value) - return result - - -def _observe_density_weight_update(recorder, model, original, *args, **kwargs): - before = _native_value(model.density_weight) - result = original(*args, **kwargs) - recorder.probe["density_weight_updates"].append( - { - "sequence": len(recorder.probe["density_weight_updates"]), - "before": before, - "after": _native_value(model.density_weight), - } - ) + recorder.probe["configured_density_weight"] = coefficient + recorder.probe["initialization_count"] += 1 return result def _observe_routability_round(recorder, original, *args, **kwargs): + result = original(*args, **kwargs) recorder.probe["routability_branch_round_count"] += 1 - return original(*args, **kwargs) - - -def _final_probe(recorder: DreamplaceRecorder) -> dict[str, Any]: - probe = dict(recorder.probe) - if recorder.model is not None: - probe["final_internal_density_weight"] = _native_value( - getattr(recorder.model, "density_weight", None) - ) - return probe + return result -def _build_dreamplace_report( - patch: dict[str, Any], - engine: Any, - ppa: dict[str, Any] | None, - probe: dict[str, Any], - *, - engine_succeeded: bool, -) -> dict[str, Any]: +def _build_dreamplace_report(patch, engine, ppa, probe, *, engine_succeeded): knob_id = patch["knob_id"] params = getattr(engine, "params", None) ppa = ppa if isinstance(ppa, dict) else {} - observation = _dreamplace_observation(knob_id, patch["value"], params, engine, ppa, probe) - initial, final, unit = _dreamplace_effective_values(knob_id, params, observation) - status = _dreamplace_activation_status( - knob_id, initial, observation, engine_succeeded=engine_succeeded - ) - outcome = "evaluated" if knob_id == "place.target_overflow" else "entered" - if status == "not_activated": - outcome = "evaluated" - evidence = _consumer_evidence(knob_id, outcome, observation) + actual = None + status = "unknown" + reason = "Required runtime observation is unavailable." + if knob_id == "place.target_density": + observation = { + key: probe.get(key) + for key in ( + "target_density", + "density_tensor_value", + "utilization_floor", + ) + } + observation["density_operator_call_count"] = probe.get("density_operator_call_count", 0) + value = observation["target_density"] + if observation["density_operator_call_count"] > 0 and _same_number( + value, observation["density_tensor_value"] + ): + actual, status, reason = value, "effective", None + elif knob_id == "place.target_overflow": + threshold = _scalar_value(getattr(params, "stop_overflow", None)) + final = _scalar_value(ppa.get("overflow")) + # DREAMPlace uses -1 when no global-placement overflow was measured. + if final is not None and final < 0: + final = None + observation = {"stop_overflow": threshold, "final_overflow": final} + if threshold is not None and final is not None: + if final < threshold: + actual, status, reason = threshold, "effective", None + else: + status, reason = "inactive", "Final overflow did not fall below the threshold." + elif knob_id == "place.cell_padding_x": + padding = probe.get("cell_padding", {}) + observation = { + "padding_sites": padding.get("padding_sites"), + "geometry_apply_count": padding.get("geometry_apply_count", 0), + } + if observation["padding_sites"] is not None and observation["geometry_apply_count"] > 0: + actual = observation["padding_sites"] + if actual == 0 and patch["value"] > 0: + status, reason = "inactive", "The requested positive padding was reduced to zero." + else: + status, reason = "effective", None + elif knob_id == "place.density_weight": + observation = { + "configured_density_weight": probe.get("configured_density_weight"), + "initialization_count": probe.get("initialization_count", 0), + } + if ( + observation["configured_density_weight"] is not None + and observation["initialization_count"] > 0 + ): + actual, status, reason = observation["configured_density_weight"], "effective", None + else: + configured = _scalar_value(getattr(params, "routability_opt_flag", None)) + configured = bool(configured) if configured in (0, 1) else None + observation = { + "configured_routability_opt": configured, + "branch_round_count": probe.get("routability_branch_round_count", 0), + "placement_completed": probe.get("placement_completed", False), + "place_object_count": probe.get("place_object_count", 0), + } + if configured is True and patch["value"] is True and observation["branch_round_count"] > 0: + actual, status, reason = True, "effective", None + elif observation["placement_completed"] and observation["place_object_count"] > 0: + if ( + configured is False + and patch["value"] is False + and observation["branch_round_count"] == 0 + ): + actual, status, reason = False, "effective", None + elif configured is not None: + status, reason = "inactive", "The requested routability behavior did not occur." return { + "schema_version": "tool.parameter_runtime_report.v2", "knob_id": knob_id, - "requested_value": patch["value"], + "written_value": patch["value"], "tool": { "name": "DREAMPlace", "revision": DREAMPLACE_OBSERVER_REVISION, "source_sha256": sha256_path(Path(__file__)), }, - "application_status": ( - "applied" if engine_succeeded and initial is not None else "unknown" - ), - "effective_initial": {"value": initial, "unit": unit}, - "effective_final": {"value": final, "unit": unit}, - "activation": { - "status": status, - "consumers": [evidence] if status in {"used", "not_activated"} else [], - }, - "transitions": _dreamplace_transitions( - knob_id, patch["value"], initial, evidence, observation - ) - if status == "used" - else [], - "consumer_observation": observation, - } - - -def _consumer_evidence(knob_id: str, outcome: str, observation: dict) -> dict: - consumer_id = _dreamplace_consumer(knob_id) - payload = { - "consumer_id": consumer_id, - "outcome": outcome, - "consumer_observation": observation, - } - return { - "consumer_id": consumer_id, - "outcome": outcome, - "evidence_ref": RUNTIME_REPORT_REF, - "evidence_sha256": sha256_bytes(canonical_json_bytes(payload)), - } - - -def _dreamplace_observation(knob_id, requested, params, engine, ppa, probe) -> dict: - iterations = ppa.get("iteration") - handlers = { - "place.target_density": _target_density_observation, - "place.target_overflow": _target_overflow_observation, - "place.cell_padding_x": _cell_padding_observation, - "place.density_weight": _density_weight_observation, - "place.routability_opt": _routability_observation, - } - return handlers[knob_id](requested, params, engine, ppa, probe, iterations) - - -def _target_density_observation(requested, params, engine, _ppa, probe, iterations): - tensor = _scalar_value( - getattr( - getattr(getattr(engine, "placer", None), "data_collections", None), - "target_density", - None, - ) - ) - effective = _scalar_value(getattr(params, "target_density", None)) - calls = probe.get("density_operator_call_count", 0) - return { - "requested_target_density": requested, - "effective_target_density": effective, - "density_tensor_value": tensor, - "density_operator_call_count": calls, - "placement_iteration_count": iterations, - "evidence_complete": _valid_iterations(iterations) - and _same_number(tensor, effective) - and calls > 0, - "lifecycle": _lifecycle( - ("adopted", effective, "ratio", "direct_python_runtime"), - ("consumed", tensor, "ratio", "direct_python_runtime"), - ), - } - - -def _target_overflow_observation(_requested, params, engine, ppa, probe, iterations): - overflows = _native_overflow_values(engine) - threshold = _scalar_value(getattr(params, "stop_overflow", None)) - read_count = probe.get("stop_overflow_read_count", 0) - final = _scalar_value(ppa.get("overflow")) - return { - "effective_stop_overflow": threshold, - "final_overflow": final, - "placement_iteration_count": iterations, - "predicate_owner_call_count": probe.get("nonlinear_place_call_count", 0), - "threshold_read_count": read_count, - "observed_overflow_count": len(overflows), - "minimum_observed_overflow": min(overflows) if overflows else None, - "threshold_reached": min(overflows) <= threshold - if overflows and threshold is not None - else None, - "evidence_complete": _valid_iterations(iterations) - and threshold is not None - and read_count > 0, - "lifecycle": _lifecycle( - ("adopted", threshold, "ratio", "direct_python_runtime"), - ("consumed", threshold, "ratio", "direct_python_runtime"), - ("realized", final, "overflow", "post_run_state"), - ), - } - - -def _cell_padding_observation(requested, params, engine, _ppa, probe, iterations): - placedb = getattr(engine, "placedb", None) - padding = probe.get("cell_padding", {}) - effective_dbu = _scalar_value(padding.get("effective_padding_dbu")) - restored = _scalar_value(getattr(params, "cell_padding_x", None)) - movable = getattr(placedb, "num_movable_nodes", None) - return { - "requested_padding_dbu": requested, - "normalized_padding_dbu": padding.get("normalized_padding_dbu"), - "effective_padding_dbu": effective_dbu, - "effective_padding_sites": _scalar_value(getattr(placedb, "cell_padding_x", None)), - "post_legalization_padding_sites": restored, - "representation_restored": restored == 0, - "geometry_apply_count": padding.get("geometry_apply_count", 0), - "movable_node_count": movable, - "placement_iteration_count": iterations, - "evidence_complete": _valid_iterations(iterations) - and effective_dbu is not None - and type(movable) is int - and padding.get("geometry_apply_count", 0) > 0, - "lifecycle": _lifecycle( - ( - "normalized", - padding.get("normalized_padding_dbu"), - "dbu", - "direct_python_runtime", - ), - ("consumed", effective_dbu, "dbu", "direct_python_runtime"), - ("restored", restored, "internal_site", "post_run_state"), - ), - } - - -def _density_weight_observation(_requested, params, _engine, ppa, probe, iterations): - initializations = probe.get("density_weight_initializations", []) - updates = probe.get("density_weight_updates", []) - initial = initializations[0] if initializations else None - final = probe.get("final_internal_density_weight") - if final is None: - final = updates[-1]["after"] if updates else initial - objective = _scalar_value(ppa.get("objective")) - return { - "configured_density_weight": _scalar_value(getattr(params, "density_weight", None)), - "internal_initial_density_weight": initial, - "density_weight_updates": updates, - "density_weight_update_count": len(updates), - "final_internal_density_weight": final, - "final_objective": objective, - "placement_iteration_count": iterations, - "evidence_complete": _valid_iterations(iterations) - and _runtime_scalar(initial) is not None - and _runtime_scalar(final) is not None - and objective is not None, - "lifecycle": _lifecycle( - ("adopted", initial, "internal_objective_weight", "direct_python_runtime"), - ("evolved", final, "internal_objective_weight", "direct_python_runtime"), - ), + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, } -def _routability_observation(_requested, params, _engine, _ppa, probe, iterations): - rounds = probe.get("routability_branch_round_count") - configured = _scalar_value(getattr(params, "routability_opt_flag", None)) - place_objects = probe.get("place_object_count", 0) - return { - "configured_routability_opt": configured, - "operator_constructed": probe.get("routability_operator_constructed", False), - "branch_round_count": rounds, - "place_object_count": place_objects, - "placement_iteration_count": iterations, - "evidence_complete": type(rounds) is int and place_objects > 0, - "lifecycle": _lifecycle( - ("adopted", configured, "boolean", "direct_python_runtime"), - ("consumed", rounds, "branch_round_count", "direct_python_runtime"), - ), - } - - -def _dreamplace_effective_values(knob_id, params, observation) -> tuple[Any, Any, str]: - if knob_id == "place.target_density": - value = observation["effective_target_density"] - return value, value, "ratio" - if knob_id == "place.target_overflow": - value = observation["effective_stop_overflow"] - return value, value, "ratio" - if knob_id == "place.cell_padding_x": - value = observation["effective_padding_dbu"] - return value, value, "dbu" - if knob_id == "place.density_weight": - return ( - _runtime_scalar(observation["internal_initial_density_weight"]), - _runtime_scalar(observation["final_internal_density_weight"]), - "internal_objective_weight", - ) - value = _scalar_value(getattr(params, "routability_opt_flag", None)) - return value, value, "boolean" - - -def _dreamplace_activation_status( - knob_id: str, - effective: Any, - observation: dict[str, Any], - *, - engine_succeeded: bool, -) -> str: - if not engine_succeeded or not observation.get("evidence_complete"): - return "unknown" - if knob_id == "place.routability_opt" and ( - effective in (False, 0) or not observation.get("branch_round_count") - ): - return "not_activated" - if knob_id == "place.cell_padding_x" and effective == 0: - return "not_activated" - return "used" - - -def _dreamplace_transitions(knob_id, requested, effective, evidence, observation): - if ( - knob_id == "place.target_density" - and isinstance(effective, (int, float)) - and effective > requested - ): - return [_transition("materialized", "overridden", effective, evidence)] - normalized = observation.get("normalized_padding_dbu") - if knob_id == "place.cell_padding_x" and (normalized is not None and effective != normalized): - return [_transition("normalized", "clamped", effective, evidence)] - return [] - - -def _transition(source: str, target: str, value: Any, evidence: dict) -> dict: - transition = { - "sequence": 0, - "from": source, - "to": target, - "value": value, - "reason": { - "overridden": "DREAMPlace utilization lower bound", - "clamped": "DREAMPlace movable-area padding cap", - }[target], - "evidence_ref": RUNTIME_REPORT_REF, - "evidence_sha256": evidence["evidence_sha256"], - } - if target == "overridden": - transition["rule_id"] = "dreamplace.target_density.utilization_floor" - return transition - - -def _dreamplace_consumer(knob_id: str) -> str: - return { - "place.target_density": "dreamplace.density_objective", - "place.target_overflow": "dreamplace.overflow_predicate", - "place.cell_padding_x": "dreamplace.cell_size_expansion", - "place.routability_opt": "dreamplace.routability_branch", - "place.density_weight": "dreamplace.density_preconditioner", - }[knob_id] - - -def _lifecycle(*events: tuple[str, Any, str, str]) -> list[dict[str, Any]]: - return [ - { - "sequence": sequence, - "phase": phase, - "value": value, - "unit": unit, - "evidence_kind": evidence_kind, - } - for sequence, (phase, value, unit, evidence_kind) in enumerate(events) - ] - - -def _native_overflow_values(engine: Any) -> list[float]: - metrics = getattr(engine, "metrics", None) - values = metrics.get("overflow", []) if isinstance(metrics, dict) else [] - return [value for item in values if (value := _scalar_value(item)) is not None] - - -def _native_value(value: Any): - for operation in ("detach", "cpu", "tolist"): - with suppress(AttributeError, RuntimeError, TypeError, ValueError): - value = getattr(value, operation)() - if isinstance(value, list): - values = [_native_value(item) for item in value] - return values[0] if len(values) == 1 else values - with suppress(AttributeError, RuntimeError, TypeError, ValueError): - value = value.item() - return _finite_scalar(value) - - -def _scalar_value(value: Any): +def _scalar_value(value): with suppress(AttributeError, RuntimeError, TypeError, ValueError): value = value.item() - return _finite_scalar(value) - - -def _finite_scalar(value: Any): - if type(value) is bool or type(value) is int: + if type(value) in {bool, int}: return value - if type(value) is float and math.isfinite(value): - return value - return None - - -def _runtime_scalar(value: Any): - return value if type(value) in {int, float} and math.isfinite(value) else None - - -def _valid_iterations(value: Any) -> bool: - return type(value) is int and value > 0 + return value if type(value) is float and math.isfinite(value) else None -def _same_number(left: Any, right: Any) -> bool: +def _same_number(left, right): return ( - isinstance(left, (int, float)) - and isinstance(right, (int, float)) + type(left) in {int, float} + and type(right) in {int, float} and math.isclose(left, right, rel_tol=1e-6, abs_tol=1e-7) ) diff --git a/agent/test/test_floorplan_mode.py b/agent/test/test_floorplan_mode.py index 0e267a82a..0f5058d44 100644 --- a/agent/test/test_floorplan_mode.py +++ b/agent/test/test_floorplan_mode.py @@ -290,9 +290,11 @@ def native(**_kwargs): { "init_fp_call_count": 1, "run_fp_call_count": 1, + "run_fp_completed": True, "config_path": str(workspace.config["Floorplan"]), }, feature, engine_succeeded=True, ) - assert report["activation"]["status"] == ("used" if mode == "die_util" else "not_activated") + assert report["status"] == ("effective" if mode == "die_util" else "inactive") + assert report["actual_value"] == (value if mode == "die_util" else None) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 6e6845619..8f36763bd 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -23,22 +23,23 @@ PRODUCER = Path(__file__).parents[1] / "data/parameter_runtime_observer.py" TOOL = { "name": "DREAMPlace", - "revision": "ecc.agent.dreamplace_parameter_observer.v1", + "revision": "ecc.agent.dreamplace_parameter_observer.v2", "source_sha256": sha256_path(PRODUCER), } -def _write_unknown_runtime_report(analysis: Path, *, knob_id: str, requested_value: object) -> None: - (analysis / "parameter_runtime_report.v1.json").write_text( +def _write_unknown_runtime_report(analysis: Path, *, knob_id: str, written_value: object) -> None: + (analysis / "parameter_runtime_report.v2.json").write_text( json.dumps( { "knob_id": knob_id, - "requested_value": requested_value, + "written_value": written_value, "tool": TOOL, - "application_status": "unknown", - "activation": {"status": "unknown", "consumers": []}, - "effective_initial": {"value": None, "unit": "ratio"}, - "effective_final": {"value": None, "unit": "ratio"}, + "schema_version": "tool.parameter_runtime_report.v2", + "status": "unknown", + "actual_value": None, + "reason": "Required runtime observation is unavailable.", + "observation": {}, } ), encoding="utf-8", @@ -100,7 +101,7 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No _write_unknown_runtime_report( analysis, knob_id="place.target_density", - requested_value=0.85, + written_value=0.85, ) request = SimpleNamespace( candidate_id="candidate-1", @@ -119,7 +120,7 @@ def test_candidate_parameter_receipt_is_written_atomically(tmp_path: Path) -> No parent_flow_sha256=HASH, ) - receipt_path = analysis / "parameter_application_receipt.v1.json" + receipt_path = analysis / "parameter_application_receipt.v2.json" assert receipt_path.is_file() assert json.loads(receipt_path.read_text(encoding="utf-8")) == receipt assert sha256_path(receipt_path) is not None @@ -217,7 +218,7 @@ def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monke _write_unknown_runtime_report( tmp_path / "analysis", knob_id="place.cell_padding_x", - requested_value=200, + written_value=200, ) receipt = _candidate_parameter_receipt( workspace, @@ -231,7 +232,7 @@ def test_cell_padding_receipt_preserves_surface_site_value(tmp_path: Path, monke assert receipt["materialization"]["unit"] == "dbu" -def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> None: +def test_candidate_parameter_receipt_rejects_incomplete_materialization(tmp_path: Path) -> None: analysis = tmp_path / "analysis" analysis.mkdir() materialization = analysis / "candidate_materialization.v1.json" @@ -258,7 +259,7 @@ def test_candidate_parameter_receipt_rejects_incomplete_l1(tmp_path: Path) -> No ) -def test_candidate_receipt_preserves_native_consumer_observation_and_transition( +def test_candidate_receipt_preserves_minimal_runtime_observation( tmp_path: Path, ) -> None: workspace, materialization = _materialized_workspace( @@ -270,44 +271,22 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( ) analysis = tmp_path / "analysis" observation = { - "requested_target_density": 0.2, - "effective_target_density": 0.8, + "target_density": 0.8, "density_tensor_value": 0.8, - "placement_iteration_count": 4, - "evidence_complete": True, + "density_operator_call_count": 4, + "utilization_floor": 0.8, } - transition = { - "sequence": 0, - "from": "materialized", - "to": "overridden", - "value": 0.8, - "reason": "DREAMPlace utilization lower bound", - "rule_id": "dreamplace.target_density.utilization_floor", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": HASH, - } - (analysis / "parameter_runtime_report.v1.json").write_text( + (analysis / "parameter_runtime_report.v2.json").write_text( json.dumps( { + "schema_version": "tool.parameter_runtime_report.v2", "knob_id": "place.target_density", - "requested_value": 0.2, + "written_value": 0.2, "tool": TOOL, - "application_status": "applied", - "effective_initial": {"value": 0.8, "unit": "ratio"}, - "effective_final": {"value": 0.8, "unit": "ratio"}, - "activation": { - "status": "used", - "consumers": [ - { - "consumer_id": "dreamplace.density_objective", - "outcome": "entered", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": HASH, - } - ], - }, - "consumer_observation": observation, - "transitions": [transition], + "status": "effective", + "actual_value": 0.8, + "reason": None, + "observation": observation, } ), encoding="utf-8", @@ -329,8 +308,10 @@ def test_candidate_receipt_preserves_native_consumer_observation_and_transition( parent_flow_sha256=HASH, ) - assert receipt["consumer_observation"] == observation - assert receipt["transitions"] == [transition] + assert receipt["observation"] == observation + assert receipt["actual_value"] == 0.8 + assert receipt["status"] == "effective" + assert receipt["schema_version"] == "tool.parameter_application_receipt.v2" def test_candidate_parameter_receipt_rejects_runtime_report_for_another_knob( @@ -343,31 +324,8 @@ def test_candidate_parameter_receipt_rejects_runtime_report_for_another_knob( before=0.5, written=0.85, ) - (tmp_path / "analysis" / "parameter_runtime_report.v1.json").write_text( - json.dumps( - { - "knob_id": "place.density_weight", - "requested_value": 0.001, - "tool": TOOL, - "application_status": "applied", - "effective_initial": {"value": 0.001, "unit": "objective_weight"}, - "effective_final": {"value": 0.001, "unit": "objective_weight"}, - "activation": { - "status": "used", - "consumers": [ - { - "consumer_id": "dreamplace.density_preconditioner", - "outcome": "entered", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": HASH, - } - ], - }, - "consumer_observation": {"evidence_complete": True}, - "transitions": [], - } - ), - encoding="utf-8", + _write_unknown_runtime_report( + tmp_path / "analysis", knob_id="place.density_weight", written_value=0.001 ) request = SimpleNamespace( candidate_id="candidate-density", @@ -401,7 +359,7 @@ def test_candidate_parameter_receipt_requires_parent_flow_sha256( _write_unknown_runtime_report( tmp_path / "analysis", knob_id="place.target_density", - requested_value=0.85, + written_value=0.85, ) request = SimpleNamespace( candidate_id="candidate-no-parent", @@ -435,7 +393,7 @@ def test_candidate_parameter_receipt_rejects_stripped_unknown_ecc_revision( _write_unknown_runtime_report( tmp_path / "analysis", knob_id="place.target_density", - requested_value=0.85, + written_value=0.85, ) request = SimpleNamespace( candidate_id="candidate-unknown-revision", @@ -465,5 +423,11 @@ def test_parameter_receipt_rejects_unbound_tool_metadata() -> None: context={"stage": "place"}, requested={"knob_id": "place.target_density", "value": 0.85, "unit": "ratio"}, materialization={}, - runtime_report={"activation": {"status": "unknown", "consumers": []}}, + runtime_report={ + "schema_version": "tool.parameter_runtime_report.v2", + "status": "unknown", + "actual_value": None, + "reason": "Not observed.", + "observation": {}, + }, ) diff --git a/agent/test/test_parameter_runtime_observer.py b/agent/test/test_parameter_runtime_observer.py index a8b4f463a..98d9a7cba 100644 --- a/agent/test/test_parameter_runtime_observer.py +++ b/agent/test/test_parameter_runtime_observer.py @@ -3,390 +3,229 @@ from threading import Thread from types import SimpleNamespace +import pytest + from agent.data.floorplan_parameter_observer import build_floorplan_report from agent.data.parameter_runtime_observer import ( DreamplaceRecorder, _build_dreamplace_report, _invoke_and_record, - _native_value, + _observe_cell_padding, _observe_native_model, + _observe_placement_call, _patch_method, ) -class _Scalar: - def __init__(self, value): - self.value = value - - def item(self): - return self.value +def _report(knob, value, params, probe, *, succeeded=True): + return _build_dreamplace_report( + {"knob_id": knob, "value": value}, + SimpleNamespace(params=SimpleNamespace(**params)), + {}, + probe, + engine_succeeded=succeeded, + ) -def _engine(*, params, target_density=0.8, padding_sites=2): - return SimpleNamespace( - params=params, - placer=SimpleNamespace( - data_collections=SimpleNamespace(target_density=_Scalar(target_density)) - ), - placedb=SimpleNamespace( - cell_padding_x=padding_sites, - num_movable_nodes=12, - ), - metrics={"overflow": [0.7, _Scalar(0.12), 0.08]}, +def test_density_floor_remains_effective_if_later_tool_operation_fails(): + report = _report( + "place.target_density", + 0.2, + {}, + { + "target_density": 0.65, + "density_tensor_value": 0.64999998, + "density_operator_call_count": 3, + "utilization_floor": 0.65, + }, + succeeded=False, ) + assert (report["status"], report["actual_value"]) == ("effective", 0.65) + assert report["observation"]["utilization_floor"] == 0.65 + assert report["schema_version"] == "tool.parameter_runtime_report.v2" -def test_density_weight_report_tracks_internal_values_not_only_configured_value(tmp_path): - params = SimpleNamespace(density_weight=0.001) - probe = { - "density_weight_initializations": [0.004], - "density_weight_updates": [{"sequence": 0, "before": 0.004, "after": 0.006}], - "final_internal_density_weight": 0.009, - } +def test_configured_density_without_consumer_is_unknown(): + report = _report("place.target_density", 0.2, {"target_density": 0.65}, {}) + assert (report["status"], report["actual_value"]) == ("unknown", None) - report = _build_dreamplace_report( - {"knob_id": "place.density_weight", "value": 0.001}, - _engine(params=params), - {"iteration": 5, "objective": 12.5}, - probe, - engine_succeeded=True, - ) - observation = report["consumer_observation"] - assert observation["configured_density_weight"] == 0.001 - assert observation["internal_initial_density_weight"] == 0.004 - assert observation["final_internal_density_weight"] == 0.009 - assert report["effective_initial"] == { - "value": 0.004, - "unit": "internal_objective_weight", - } - assert report["effective_final"] == { - "value": 0.009, - "unit": "internal_objective_weight", - } - assert observation["lifecycle"] == [ - { - "sequence": 0, - "phase": "adopted", - "value": 0.004, - "unit": "internal_objective_weight", - "evidence_kind": "direct_python_runtime", - }, - { - "sequence": 1, - "phase": "evolved", - "value": 0.009, - "unit": "internal_objective_weight", - "evidence_kind": "direct_python_runtime", - }, - ] +def test_completed_placement_preserves_overflow_before_later_failure(): + recorder = DreamplaceRecorder({"knob_id": "place.target_overflow", "value": 0.1}) + engine = SimpleNamespace(params=SimpleNamespace(stop_overflow=0.1)) + def place(engine): + engine.metrics = {"overflow": [0.7, 0.08]} -def test_target_density_report_requires_operator_call_and_records_floor_override(tmp_path): - params = SimpleNamespace(target_density=0.65) + _observe_placement_call(recorder, place, engine) report = _build_dreamplace_report( - {"knob_id": "place.target_density", "value": 0.2}, - _engine(params=params, target_density=0.6499999761581421), - {"iteration": 4}, - {"density_operator_call_count": 3}, - engine_succeeded=True, + recorder.patch, + engine, + recorder.ppa, + recorder.probe, + engine_succeeded=False, ) - - assert report["activation"]["status"] == "used" - assert report["effective_initial"] == {"value": 0.65, "unit": "ratio"} - assert report["transitions"] == [ - { - "sequence": 0, - "from": "materialized", - "to": "overridden", - "value": 0.65, - "reason": "DREAMPlace utilization lower bound", - "rule_id": "dreamplace.target_density.utilization_floor", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": report["activation"]["consumers"][0]["evidence_sha256"], - } - ] - - -def test_target_overflow_report_binds_threshold_to_running_predicate_owner(tmp_path): - params = SimpleNamespace(stop_overflow=0.1) - report = _build_dreamplace_report( - {"knob_id": "place.target_overflow", "value": 0.1}, - _engine(params=params), - {"iteration": 7, "overflow": 0.08}, + assert (report["status"], report["actual_value"]) == ("effective", 0.1) + + +@pytest.mark.parametrize( + "requested,rounds,completed,status,actual", + [ + (False, 0, True, "effective", False), + (True, 1, False, "effective", True), + (True, 0, True, "inactive", None), + (True, 0, False, "unknown", None), + (False, 0, False, "unknown", None), + ], +) +def test_routability_disable_and_untriggered_enable(requested, rounds, completed, status, actual): + report = _report( + "place.routability_opt", + requested, + {"routability_opt_flag": requested}, { - "nonlinear_place_call_count": 1, - "stop_overflow_read_count": 4, + "place_object_count": 1, + "routability_branch_round_count": rounds, + "placement_completed": completed, }, - engine_succeeded=True, + succeeded=completed, ) + assert (report["status"], report["actual_value"]) == (status, actual) - observation = report["consumer_observation"] - assert report["activation"]["status"] == "used" - assert report["activation"]["consumers"][0]["outcome"] == "evaluated" - assert observation["predicate_owner_call_count"] == 1 - assert observation["threshold_read_count"] == 4 - assert observation["observed_overflow_count"] == 3 - assert observation["threshold_reached"] is True - assert observation["lifecycle"][1]["evidence_kind"] == "direct_python_runtime" - - -def test_routability_report_distinguishes_disabled_gate_from_entered_branch(tmp_path): - disabled = _build_dreamplace_report( - {"knob_id": "place.routability_opt", "value": False}, - _engine(params=SimpleNamespace(routability_opt_flag=False)), - {"iteration": 3}, - { + +def test_tool_disabled_flag_does_not_fulfill_enable_request(): + report = _report( + "place.routability_opt", + value=True, + params={"routability_opt_flag": False}, + probe={ "place_object_count": 1, - "routability_operator_constructed": False, "routability_branch_round_count": 0, + "placement_completed": True, }, - engine_succeeded=True, ) - entered = _build_dreamplace_report( - {"knob_id": "place.routability_opt", "value": True}, - _engine(params=SimpleNamespace(routability_opt_flag=True)), - {"iteration": 3}, - { - "place_object_count": 1, - "routability_operator_constructed": True, - "routability_branch_round_count": 1, - }, - engine_succeeded=True, + assert (report["status"], report["actual_value"]) == ("inactive", None) + + +@pytest.mark.parametrize( + "written,sites,status", + [ + (400, 1, "effective"), + (0, 0, "effective"), + (400, 0, "inactive"), + ], +) +def test_padding_uses_sites_and_distinguishes_deliberate_zero(written, sites, status): + report = _report( + "place.cell_padding_x", + written, + {}, + {"cell_padding": {"padding_sites": sites, "geometry_apply_count": 1}}, ) + assert (report["status"], report["actual_value"]) == (status, sites) + assert report["written_value"] == written - assert disabled["activation"]["status"] == "not_activated" - assert disabled["activation"]["consumers"][0]["outcome"] == "evaluated" - assert entered["activation"]["status"] == "used" - assert entered["consumer_observation"]["branch_round_count"] == 1 +def test_padding_capture_converts_before_database_scaling(): + recorder = DreamplaceRecorder({"knob_id": "place.cell_padding_x", "value": 400}) + placedb = SimpleNamespace(site_width=200, cell_padding_x=0) -def test_padding_report_keeps_written_consumed_internal_and_restored_values_distinct( - tmp_path, -): - params = SimpleNamespace(cell_padding_x=0) - probe = { - "cell_padding": { - "normalized_padding_dbu": 400, - "effective_padding_dbu": 200, - "geometry_apply_count": 1, - } - } + def apply(db, _params): + db.cell_padding_x = 200 - report = _build_dreamplace_report( - {"knob_id": "place.cell_padding_x", "value": 400}, - _engine(params=params, padding_sites=1), - {"iteration": 3}, - probe, - engine_succeeded=True, + _observe_cell_padding(recorder, apply, placedb, SimpleNamespace(cell_padding_x=400)) + assert recorder.probe["cell_padding"] == {"padding_sites": 1, "geometry_apply_count": 1} + + +def test_density_weight_uses_coefficient_not_internal_tensor(): + recorder = DreamplaceRecorder({"knob_id": "place.density_weight", "value": 0.001}) + model = SimpleNamespace( + op_collections=SimpleNamespace(), + initialize_density_weight=lambda _params, _db: [0.004, 0.005], ) + params = SimpleNamespace(density_weight=0.001) + with ExitStack() as stack: + _observe_native_model(model, recorder, stack) + assert model.initialize_density_weight(params, None) == [0.004, 0.005] + report = _report("place.density_weight", 0.001, {}, recorder.probe, succeeded=False) + assert (report["status"], report["actual_value"]) == ("effective", 0.001) + assert report["observation"] == {"configured_density_weight": 0.001, "initialization_count": 1} - assert report["effective_initial"] == {"value": 200, "unit": "dbu"} - assert report["effective_final"] == {"value": 200, "unit": "dbu"} - assert report["consumer_observation"] == { - "requested_padding_dbu": 400, - "normalized_padding_dbu": 400, - "effective_padding_dbu": 200, - "effective_padding_sites": 1, - "post_legalization_padding_sites": 0, - "representation_restored": True, - "geometry_apply_count": 1, - "movable_node_count": 12, - "placement_iteration_count": 3, - "evidence_complete": True, - "lifecycle": [ - { - "sequence": 0, - "phase": "normalized", - "value": 400, - "unit": "dbu", - "evidence_kind": "direct_python_runtime", - }, - { - "sequence": 1, - "phase": "consumed", - "value": 200, - "unit": "dbu", - "evidence_kind": "direct_python_runtime", - }, - { - "sequence": 2, - "phase": "restored", - "value": 0, - "unit": "internal_site", - "evidence_kind": "post_run_state", - }, - ], - } - - -def test_floorplan_report_separates_boundary_value_from_realized_geometry(tmp_path): - config_path = tmp_path / "floorplan.json" - config_path.write_text( + +@pytest.mark.parametrize( + "knob,value", [("floorplan.core_util", 0.8), ("floorplan.aspect_ratio", 1.0)] +) +@pytest.mark.parametrize("mode,status", [("die_util", "effective"), ("die_size", "inactive")]) +def test_floorplan_actual_is_input_not_geometry(tmp_path, knob, value, mode, status): + config = tmp_path / "fp.json" + config.write_text( json.dumps( - { - "die_builder": { - "mode": "die_util", - "die_util": {"utilization": 0.8, "aspect_ratio": 1.0}, - } - } - ), - encoding="utf-8", + {"die_builder": {"mode": mode, "die_util": {"utilization": 0.8, "aspect_ratio": 1.0}}} + ) ) - feature_path = tmp_path / "feature.json" - feature_path.write_text( + feature = tmp_path / "feature.json" + feature.write_text( json.dumps( { "Design Layout": { - "core_area": 800.0, "core_usage": 0.79, "core_bounding_width": 40.0, "core_bounding_height": 20.0, } } - ), - encoding="utf-8", + ) ) - boundary = { - "init_fp_call_count": 1, - "run_fp_call_count": 1, - "config_path": str(config_path), - } - report = build_floorplan_report( - {"knob_id": "floorplan.core_util", "value": 0.8}, - boundary, - feature_path, - engine_succeeded=True, + {"knob_id": knob, "value": value}, + { + "config_path": str(config), + "init_fp_call_count": 1, + "run_fp_call_count": 1, + "run_fp_completed": True, + }, + feature, + engine_succeeded=False, ) + assert (report["status"], report["actual_value"]) == ( + status, + value if mode == "die_util" else None, + ) + - assert report["effective_initial"] == {"value": 0.8, "unit": "ratio"} - assert report["effective_final"] == {"value": 0.79, "unit": "ratio"} - observation = report["consumer_observation"] - assert observation["evidence_kind"] == "boundary_and_derived_output" - assert observation["realized_core_utilization"] == 0.79 - assert observation["realized_aspect_ratio"] == 2.0 - assert observation["lifecycle"][-1] == { - "sequence": 2, - "phase": "realized", - "value": 0.79, - "unit": "ratio", - "evidence_kind": "derived_verified_artifact", - } - - -def test_scoped_method_hook_is_restored_after_candidate(): +def test_scoped_method_restored_and_ignores_other_threads(): class Owner: def run(self): return "original" original = Owner.run with ExitStack() as stack: - _patch_method( - stack, - Owner, - "run", - lambda wrapped, owner: (wrapped(owner), "observed"), - ) + _patch_method(stack, Owner, "run", lambda wrapped, owner: (wrapped(owner), "observed")) assert Owner().run() == ("original", "observed") - assert Owner.run is not original - foreign_result = [] - thread = Thread(target=lambda: foreign_result.append(Owner().run())) + results = [] + thread = Thread(target=lambda: results.append(Owner().run())) thread.start() thread.join() - assert foreign_result == ["original"] - + assert results == ["original"] assert Owner.run is original -def test_scoped_callable_hook_preserves_wrapped_operator_methods(): +def test_scoped_callable_preserves_operator_methods(): class Operation: - __name__ = "density_op" - __qualname__ = "Operation.density_op" - __annotations__ = {} - - def __init__(self): - self.reset_calls = 0 - def __call__(self): return "original" def reset(self): - self.reset_calls += 1 + return "reset" owner = SimpleNamespace(density_op=Operation()) original = owner.density_op - with ExitStack() as stack: - _patch_method( - stack, - owner, - "density_op", - lambda wrapped: (wrapped(), "observed"), - ) + _patch_method(stack, owner, "density_op", lambda wrapped: (wrapped(), "observed")) assert owner.density_op() == ("original", "observed") - owner.density_op.reset() - - assert original.reset_calls == 1 + assert owner.density_op.reset() == "reset" assert owner.density_op is original -def test_native_model_hook_records_density_updates_and_routability_calls(): - recorder = DreamplaceRecorder( - patch={"knob_id": "place.density_weight", "value": 0.001}, - ) - model = SimpleNamespace(density_weight=0.0) - - def initialize_density_weight(): - model.density_weight = 0.004 - return model.density_weight - - def update_density_weight(): - model.density_weight = 0.006 - return "updated" - - model.initialize_density_weight = initialize_density_weight - model.op_collections = SimpleNamespace( - update_density_weight_op=update_density_weight, - adjust_node_area_op=lambda: "adjusted", - ) - - with ExitStack() as stack: - _observe_native_model(model, recorder, stack) - assert model.initialize_density_weight() == 0.004 - assert model.op_collections.update_density_weight_op() == "updated" - assert recorder.probe["density_weight_initializations"] == [0.004] - assert recorder.probe["density_weight_updates"] == [ - {"sequence": 0, "before": 0.004, "after": 0.006} - ] - - -def test_native_value_preserves_vectors_and_drops_non_finite_values(): - assert _native_value([_Scalar(0.1), _Scalar(0.2)]) == [0.1, 0.2] - assert _native_value(float("inf")) is None - - -def test_density_weight_vector_is_preserved_without_claiming_scalar_effectiveness(): - report = _build_dreamplace_report( - {"knob_id": "place.density_weight", "value": 0.001}, - _engine(params=SimpleNamespace(density_weight=0.001)), - {"iteration": 4, "objective": 1.0}, - { - "density_weight_initializations": [[0.004, 0.005]], - "density_weight_updates": [], - "final_internal_density_weight": [0.006, 0.007], - }, - engine_succeeded=True, - ) - - assert report["activation"]["status"] == "unknown" - assert report["effective_initial"]["value"] is None - assert report["consumer_observation"]["final_internal_density_weight"] == [ - 0.006, - 0.007, - ] - - def test_report_failure_does_not_change_tool_result(monkeypatch, tmp_path): failures = [] workspace = SimpleNamespace( @@ -397,10 +236,6 @@ def test_report_failure_does_not_change_tool_result(monkeypatch, tmp_path): def fail_write(*_args, **_kwargs): raise OSError("read-only analysis directory") - monkeypatch.setattr( - "agent.data.parameter_runtime_observer.write_json_atomic", - fail_write, - ) - + monkeypatch.setattr("agent.data.parameter_runtime_observer.write_json_atomic", fail_write) assert _invoke_and_record(workspace, lambda: True, lambda _ok: {}) is True assert failures == ["Failed to persist parameter runtime evidence"] diff --git a/agent/test/test_parameter_status.py b/agent/test/test_parameter_status.py new file mode 100644 index 000000000..6ca99e15f --- /dev/null +++ b/agent/test/test_parameter_status.py @@ -0,0 +1,26 @@ +from types import SimpleNamespace + +import pytest + +from agent.data.parameter_runtime_observer import _build_dreamplace_report + + +@pytest.mark.parametrize( + "overflow,status,actual", + [ + (0.08, "effective", 0.1), + (0.1, "inactive", None), + (0.3, "inactive", None), + (None, "unknown", None), + (-1, "unknown", None), + ], +) +def test_overflow_final_threshold(overflow, status, actual): + report = _build_dreamplace_report( + {"knob_id": "place.target_overflow", "value": 0.1}, + SimpleNamespace(params=SimpleNamespace(stop_overflow=0.1)), + {"overflow": overflow}, + {}, + engine_succeeded=True, + ) + assert (report["status"], report["actual_value"]) == (status, actual) diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index d59a4daa8..e505fa9e8 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -155,6 +155,8 @@ def run_step(**_kwargs): ) recorder.ppa = {"iteration": 3} recorder.probe["density_operator_call_count"] = 2 + recorder.probe["target_density"] = 0.65 + recorder.probe["density_tensor_value"] = 0.65 return True tool = SimpleNamespace(build_step_config=lambda *_args: None, run_step=run_step) @@ -168,10 +170,10 @@ def capture(_patch): monkeypatch.setattr(runtime_observer, "_capture_dreamplace", capture) assert eda.run_step(workspace, step, ecc_module=True) is True - report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v1.json").read_text()) - assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v1" - assert report["activation"]["status"] == "used" - assert report["consumer_observation"]["density_operator_call_count"] == 2 + report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v2.json").read_text()) + assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v2" + assert report["status"] == "effective" + assert report["observation"]["density_operator_call_count"] == 2 def test_legalization_runner_reapplies_real_dreamplace_overlay(monkeypatch, tmp_path): diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index 62e37b847..d771c469a 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -577,40 +577,28 @@ def test_failed_candidate_returns_materialization_application_and_manifest_evide monkeypatch.setattr("agent.workspace_api._reapply_candidate_input", lambda *_args: None) tool = { "name": "DREAMPlace", - "revision": "ecc.agent.dreamplace_parameter_observer.v1", + "revision": "ecc.agent.dreamplace_parameter_observer.v2", "source_sha256": "sha256:" + "3" * 64, } def run_candidate_step(_flow, step, **_kwargs): if step.name == "place": report = { + "schema_version": "tool.parameter_runtime_report.v2", "knob_id": "place.target_density", - "requested_value": 0.6, + "written_value": 0.6, "tool": tool, - "application_status": "applied", - "effective_initial": {"value": 0.6, "unit": "ratio"}, - "effective_final": {"value": 0.6, "unit": "ratio"}, - "activation": { - "status": "used", - "consumers": [ - { - "consumer_id": "dreamplace.density_objective", - "outcome": "entered", - "evidence_ref": "analysis/parameter_runtime_report.v1.json", - "evidence_sha256": "sha256:" + "4" * 64, - } - ], - }, - "consumer_observation": { - "requested_target_density": 0.6, - "effective_target_density": 0.6, + "status": "effective", + "actual_value": 0.6, + "reason": None, + "observation": { + "target_density": 0.6, "density_tensor_value": 0.6, - "placement_iteration_count": 3, - "evidence_complete": True, + "density_operator_call_count": 3, + "utilization_floor": None, }, - "transitions": [], } - (candidate / "analysis" / "parameter_runtime_report.v1.json").write_text( + (candidate / "analysis" / "parameter_runtime_report.v2.json").write_text( json.dumps(report), encoding="utf-8" ) return @@ -641,7 +629,7 @@ def run_candidate_step(_flow, step, **_kwargs): assert terminal["result"].get("evidenceError") is None, terminal["result"].get("evidenceError") assert "parameterApplicationReceipt" in terminal["result"], terminal application = terminal["result"]["parameterApplicationReceipt"] - assert application["application_status"] == "applied" + assert application["status"] == "effective" assert application["tool"] == tool assert application["context"]["tool_revision"] == tool["revision"] assert application["context"]["context_sha256"] == CONTEXT_SHA256 @@ -666,10 +654,10 @@ def run_candidate_step(_flow, step, **_kwargs): == terminal["result"]["candidateManifestSha256"] ) assert terminal["result"]["parameterApplicationReceiptRef"] == ( - ".agent/candidates/candidate-failed/analysis/parameter_application_receipt.v1.json" + ".agent/candidates/candidate-failed/analysis/parameter_application_receipt.v2.json" ) assert terminal["result"]["parameterApplicationReceiptSha256"] == sha256_path( - candidate / "analysis" / "parameter_application_receipt.v1.json" + candidate / "analysis" / "parameter_application_receipt.v2.json" ) @@ -729,7 +717,7 @@ def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, dreamplace = tmp_path / "config" / "dreamplace.json" dreamplace.parent.mkdir() dreamplace.write_text('{"random_seed": 3000}', encoding="utf-8") - for name in ("parameter_runtime_report.v1.json", "parameter_application_receipt.v1.json"): + for name in ("parameter_runtime_report.v2.json", "parameter_application_receipt.v2.json"): (analysis / name).write_text('{"stale": true}', encoding="utf-8") flow = SimpleNamespace( workspace=SimpleNamespace( @@ -755,8 +743,8 @@ def test_candidate_rerun_removes_stale_top_level_parameter_receipts(monkeypatch, SimpleNamespace(directory=tmp_path, config={"dreamplace": dreamplace}), flow, request ) - assert not (analysis / "parameter_runtime_report.v1.json").exists() - assert not (analysis / "parameter_application_receipt.v1.json").exists() + assert not (analysis / "parameter_runtime_report.v2.json").exists() + assert not (analysis / "parameter_application_receipt.v2.json").exists() assert json.loads(dreamplace.read_text(encoding="utf-8"))["random_seed"] == 17 diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 8fd2c5542..086631b4f 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -602,8 +602,8 @@ def _candidate_workspace_receipt( ("floorplan_mode", FLOORPLAN_MODE_REF), ("candidate_materialization", "analysis/candidate_materialization.v1.json"), ("candidate_input_binding", "analysis/candidate_input_binding.v1.json"), - ("parameter_runtime_report", "analysis/parameter_runtime_report.v1.json"), - ("parameter_application_receipt", "analysis/parameter_application_receipt.v1.json"), + ("parameter_runtime_report", "analysis/parameter_runtime_report.v2.json"), + ("parameter_application_receipt", "analysis/parameter_application_receipt.v2.json"), ): artifact = candidate_root / relative if artifact.is_file() and not artifact.is_symlink(): @@ -705,9 +705,9 @@ def _candidate_rerun_result( } if parameter_receipt is not None: result["parameterApplicationReceipt"] = parameter_receipt - receipt_ref = f"{candidate_root_ref}/analysis/parameter_application_receipt.v1.json" + receipt_ref = f"{candidate_root_ref}/analysis/parameter_application_receipt.v2.json" receipt_sha256 = sha256_path( - Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" + Path(workspace.directory) / "analysis" / "parameter_application_receipt.v2.json" ) if receipt_sha256 is None: raise RuntimeApiError("command_failed", "candidate application receipt is unavailable") @@ -743,15 +743,9 @@ def _candidate_parameter_receipt( snapshot = materialization["snapshots"][0] knob_id = patch["knob_id"] unit = _parameter_unit(knob_id) - tool_name = ( - "ECC-Floorplan" - if knob_id.startswith("floorplan.") - else "ECC-CTS" - if knob_id == "cts.max_fanout" - else "DREAMPlace" - ) + tool_name = "ECC-Floorplan" if knob_id.startswith("floorplan.") else "DREAMPlace" runtime_report_path = ( - Path(workspace.directory) / "analysis" / "parameter_runtime_report.v1.json" + Path(workspace.directory) / "analysis" / "parameter_runtime_report.v2.json" ) try: runtime_report = json.loads(runtime_report_path.read_text(encoding="utf-8")) @@ -769,7 +763,7 @@ def _candidate_parameter_receipt( ): raise RuntimeApiError("command_failed", "candidate runtime report tool binding is invalid") tool = {key: runtime_tool[key] for key in ("name", "revision", "source_sha256")} - receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v1.json" + receipt_path = Path(workspace.directory) / "analysis" / "parameter_application_receipt.v2.json" if parent_flow_sha256 is None: raise RuntimeApiError("command_failed", "candidate parent flow fingerprint is unavailable") context = _parameter_receipt_context(workspace, request, parent_flow_sha256) @@ -923,8 +917,8 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest def _remove_stale_parameter_receipts(workspace_root: Path) -> None: analysis = workspace_root / "analysis" for name in ( - "parameter_runtime_report.v1.json", - "parameter_application_receipt.v1.json", + "parameter_runtime_report.v2.json", + "parameter_application_receipt.v2.json", "candidate_materialization.v1.json", ): path = analysis / name @@ -934,18 +928,6 @@ def _remove_stale_parameter_receipts(workspace_root: Path) -> None: path.unlink() -_RUNTIME_CONSUMERS_BY_KNOB = { - "floorplan.core_util": {"ifp.die_builder.die_utilization"}, - "floorplan.aspect_ratio": {"ifp.die_builder.die_aspect_ratio"}, - "cts.max_fanout": {"icts.synthesis.topology.max_fanout"}, - "place.target_density": {"dreamplace.density_objective"}, - "place.target_overflow": {"dreamplace.overflow_predicate"}, - "place.cell_padding_x": {"dreamplace.cell_size_expansion"}, - "place.routability_opt": {"dreamplace.routability_branch"}, - "place.density_weight": {"dreamplace.density_preconditioner"}, -} - - def _validate_runtime_report_binding( runtime_report: object, patch: dict, @@ -955,22 +937,12 @@ def _validate_runtime_report_binding( raise RuntimeApiError("command_failed", "candidate runtime report is invalid") knob_id = patch["knob_id"] written_patch = materialization["patch"][0] - if runtime_report.get("knob_id") != knob_id or runtime_report.get( - "requested_value" - ) != written_patch.get("value"): - raise RuntimeApiError("command_failed", "candidate runtime report binding is invalid") - activation = runtime_report.get("activation") - consumers = activation.get("consumers", []) if isinstance(activation, dict) else [] - if not isinstance(consumers, list): - raise RuntimeApiError("command_failed", "candidate runtime report consumers are invalid") - allowed = _RUNTIME_CONSUMERS_BY_KNOB.get(knob_id, set()) - if any( - not isinstance(consumer, dict) or consumer.get("consumer_id") not in allowed - for consumer in consumers + if ( + runtime_report.get("schema_version") != "tool.parameter_runtime_report.v2" + or runtime_report.get("knob_id") != knob_id + or runtime_report.get("written_value") != written_patch.get("value") ): - raise RuntimeApiError( - "command_failed", "candidate runtime report consumer binding is invalid" - ) + raise RuntimeApiError("command_failed", "candidate runtime report binding is invalid") def _candidate_source_step(flow, target_step: str) -> str: From fa13d644f3dad21d54e4d96f04f4967a706cfa83 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Tue, 8 Sep 2026 09:21:02 +0800 Subject: [PATCH 77/90] fix: delete experimental docs --- agent/FLOORPLAN_MODE.md | 61 -------------------------- agent/STA_PARALLEL.md | 96 ----------------------------------------- 2 files changed, 157 deletions(-) delete mode 100644 agent/FLOORPLAN_MODE.md delete mode 100644 agent/STA_PARALLEL.md diff --git a/agent/FLOORPLAN_MODE.md b/agent/FLOORPLAN_MODE.md deleted file mode 100644 index c4b534fad..000000000 --- a/agent/FLOORPLAN_MODE.md +++ /dev/null @@ -1,61 +0,0 @@ -# Isolated Floorplan Modes - -`candidate.rerun` accepts the optional `floorplanMode` (`floorplan_mode`) field: - -- `die_util`: derive core geometry from utilization and aspect ratio. -- `die_size`: use the candidate's existing fixed width and height, validated as - finite positive numbers. This does not introduce a new dimension-setting API. -- Omitted: preserve ordinary behavior, or inherit an isolated parent's mode. - -An explicit mode requires `targetStep: "Floorplan"`, `endStep: "Harden"`, and -`executionScope: "full_flow"`. The existing context hashes, seed, unique candidate -ID and idempotency key remain required. Unknown modes and mode changes starting -after Floorplan are rejected before an operation starts. - -Use `patch: []` with an explicit mode to run an isolated baseline without changing -a parameter. Then use its successful `candidateRootRef` as -`parentCandidateRootRef` for a one-knob candidate. For example, the mode-specific -fields for baseline preparation are: - -```json -{ - "targetStep": "Floorplan", - "endStep": "Harden", - "executionScope": "full_flow", - "floorplanMode": "die_util", - "patch": [] -} -``` - -For a subsequent parameter experiment, omit `floorplanMode` to inherit it and -provide the usual single-knob patch, such as -`[{"knob_id":"floorplan.core_util","value":0.7}]`. An explicit `die_size` on -a new Floorplan candidate switches back without changing its parent. Mode-only -baselines have input-binding and mode evidence, not a fabricated parameter -application receipt. - -## Isolation And Evidence - -All implementation lives in `agent/`. The native builder, parameters, algorithms -and ordinary workspace behavior are unchanged. The override runs after native -step-config rebuilding, before native execution. Fixed `die.size` remains in -the isolated parameters so switching back is possible; it does not override -the explicit `die_util` selection at the Agent execution boundary. - -`analysis/floorplan_mode.v1.json` binds the candidate, mode, previous mode, fixed -dimensions when applicable, patch, context hashes, seed and source config hash. -The candidate manifest binds that file, and the candidate state hash covers it -and the canonical `home/params.toml`. Workspaces without a mode receipt keep -their existing state-hash contract. -Success additionally requires the final config to retain the selected mode. -`candidate.resume` reuses the recorded mode and rejects context or artifact -drift; changing modes requires a new candidate, not a resume override. - -The ordinary ECC CLI does not apply this Agent-owned override. Use the Agent -rerun/resume path for these candidates. Existing ECOS GUI callers do not select -a new mode automatically; the caller must explicitly request the baseline. - -Compare candidates against a baseline executed in the same mode. Switching a -mode and a knob together cannot isolate the knob's effect. Config/receipt tests -prove execution wiring, not QoR improvement or signoff. Historical fixed-size -baselines and their experiment denominators are not rewritten. diff --git a/agent/STA_PARALLEL.md b/agent/STA_PARALLEL.md deleted file mode 100644 index c8f96bbde..000000000 --- a/agent/STA_PARALLEL.md +++ /dev/null @@ -1,96 +0,0 @@ -# Controlled Candidate STA Parallelism - -Only Agent candidate workspaces (`.agent/candidates/`) use this scheduler. -Ordinary flows and Harden retain their existing execution path. No corners, -reports, timing constraints, power calculations, or metric aggregation rules -are removed. All implementation changes are owned by `ecc/agent`. - -## Configuration - -Set `ECOS_AGENT_STA_WORKERS` in the environment that launches the Agent/RPC: - -- `1`: original serial runner, also the non-Linux default. -- `2`: Linux default, two independent corner processes. -- `4`: four independent corner processes, with higher memory consumption. - -Other values fail validation. Parallel execution requires Linux. Restart an -already-running Agent/RPC after changing its launch environment. - -Each corner uses a fresh spawned native process, an identical database snapshot, -and isolated temporary directories. The existing ECC runner still validates -inputs, enumerates all configured corners, aggregates metrics, and runs checks. -Artifacts are published only after all corner jobs succeed; normal failures -clear stale corner/aggregate outputs and reap remaining workers. Cancellation -is checked while jobs run. Linux parent-death signals kill workers if the RPC -parent is terminated without Python cleanup. Forced parent termination can -leave temporary directories; it does not guarantee filesystem cleanup. - -Per-corner logs remain in `sta_ecc/log/sta-corner-.log`. Agent STA memory -tracking includes descendants, retaining the existing increase-from-start -metric convention. The benchmark instead reports absolute sampled process-tree -RSS; shared resident pages may be counted more than once. - -## GCD Measurement - -Measured on 2026-09-07, Linux x86_64, AMD EPYC 9654 (384 logical CPUs), Python -3.11.14, installed `ecc=0.1.0a11`, `ecc-tools-bin=0.1.0a12`. Two runs per -configuration, serial/2/4 order repeated, on separate copies of one routed -gcd/ICS55 workspace. Cache state and machine load were not controlled. - -| Workers | STA median (s) | STA speedup | Harden median (s) | Maximum sampled tree RSS (MiB) | -| --- | ---: | ---: | ---: | ---: | -| 1 | 157.62 | 1.00x | 11.96 | 1367.00 | -| 2 | 95.76 | 1.65x | 11.74 | 2883.39 | -| 4 | 54.58 | 2.89x | 11.87 | 5555.70 | - -All six runs produced 13 timing summaries and 13 power summaries, with zero -missing configured corners. Per-corner timing/power JSON, aggregate STA/Harden -metrics, and checklist states matched the serial reference. Float comparison -uses absolute/relative tolerance `1e-9`; integer counts and key sets are exact. -Runtime and memory metrics are excluded from equivalence comparisons. - -The source workspace inventory was unchanged. Raw results and individual run -logs are in `/tmp/ecos-sta-parallel-gcd-20260907-v2`; these temporary artifacts -are not committed. Reproduce from the ECC repository with a new output path: - -```bash -./.venv/bin/python -m agent.sta_benchmark \ - --source /tmp/ecos-agent-gcd-7knob-20260906-v5/gcd-gap-4c403cfe-v12/baseline-1/workspace \ - --output /tmp/ecos-sta-parallel-gcd-reproduce \ - --workers 1 2 4 --repeats 2 -``` - -The benchmark records source hashes, implementation hashes, package/runtime -metadata, per-run metrics and logs. The initial six-run measurement predates -automatic environment metadata and the parent-death guard; its environment is -recorded above. A further guarded 2-worker native STA/Harden run completed in -95.90/12.06 seconds and matched the serial metrics; evidence is under -`/tmp/ecos-sta-parallel-guard-20260907/.agent/candidates/guard-w2`. -External PDK/library contents are not included in the workspace -inventory. No seed is added and upstream placement/routing is not rerun. - -## Acceptance Limits - -This verifies STA/Harden numerical equivalence and scheduling speed, not a new -floorplan-to-Harden optimization episode, GUI/RPC end-to-end acceptance, or -signoff. The baseline has DRC=4. Both serial and parallel runs have the same -blocked `report.sta.timing_reports` checklist item: the checker expects -`timing_max.rpt`, while native output contains `timing_max_.rpt`. -That existing report/checker mismatch is not changed or hidden here. - -Packaged/frozen execution and release builds have not been validated. Corner -reduction and native Liberty/model reuse are intentionally not implemented. - -Validation commands (from `ecc/`): - -```bash -./.venv/bin/python -m pytest -q agent/test -p no:cacheprovider -./.venv/bin/python -m pytest -q test/tools/ecc -p no:cacheprovider -./.venv/bin/ruff check agent/sta_parallel.py agent/sta_benchmark.py agent/engine.py agent/tools.py agent/test/test_sta_parallel.py agent/test/test_sta_benchmark.py agent/test/test_tools.py -git diff --check -``` - -Results: 270 Agent tests and 119 ECC tool tests passed; Ruff and whitespace -checks passed. The Agent suite includes concurrent, unrelated floorplan tests -present in the working tree. Parent-termination tests exercise both SIGTERM -and SIGKILL, including actual worker death and reaping by an isolated subreaper. From c0fab417459c288fb614a9846aa4a8b51e03980d Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Tue, 8 Sep 2026 09:36:34 +0800 Subject: [PATCH 78/90] docs: explain ecc agent rpc entrypoint --- agent/README.cn.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 agent/README.cn.md diff --git a/agent/README.cn.md b/agent/README.cn.md new file mode 100644 index 000000000..52768ebe1 --- /dev/null +++ b/agent/README.cn.md @@ -0,0 +1,63 @@ +# `ecc-agent-rpc` 入口说明 + +`pyproject.toml` 中的如下声明会在安装 ECC 时生成独立可执行文件: + +```toml +scripts.ecc-agent-rpc = "agent.rpc_server:main" +``` + +它是 ECOS Agent 专用的 ECC JSON-RPC 边车进程入口。桌面端或 Agent +运行时启动该进程,通过标准输入发送请求,并从标准输出读取结果;它不是 +供交互式使用的 `ecc` CLI,也不替代通用的 `ecc rpc serve --stdio`。 + +## `main()` 做什么 + +入口实现位于 [`rpc_server.py`](rpc_server.py)。`main()` 保持很小,只完成 +三个启动职责: + +1. 调用 `multiprocessing.freeze_support()`,使打包后的 Windows 进程可以安全 + 创建子进程; +2. 创建 `AgentRuntimeServer`,在通用 ECC runtime 方法之上注册 Agent 方法; +3. 将二进制 `stdin`/`stdout` 和该 server 交给 + `chipcompiler.runtime.stdio_server.run_stdio_server()`。 + +这样,入口不复制 transport、JSON-RPC 分发或业务执行逻辑。传输层统一处理 +请求帧、响应串行写出、`runtime.event` 通知和 `rpc.shutdown`;Agent 行为由 +`AgentRuntimeServer`、`FlowAgentRuntimeApi` 及其受控 workspace API 实现。 + +## 协议与能力 + +该进程使用带 `Content-Length` 头的 JSON-RPC 2.0 stdio 协议。标准输出只可写入 +协议帧,诊断和工具输出应写入标准错误或 workspace 日志,避免破坏客户端解码。 + +`rpc.hello` 返回的 capabilities 包含通用 runtime 方法,以及 Agent 专用方法: + +- `agent.runtime_preflight`:检查 Agent 候选执行所需运行时; +- `workspace.extract_foundation`:提取已完成 workspace 的 foundation 数据; +- `candidate.export_capabilities`、`candidate.bind_input`、 + `candidate.materialize`:查询或准备受控候选; +- `candidate.rerun`、`candidate.resume`:启动或恢复受控候选执行。 + +方法名、请求模型和处理函数的权威定义在 [`methods.py`](methods.py)。入口收到 +请求后会将 camelCase 字段归一化为请求模型字段;无效字段或重复字段返回 +`invalid_request`,不会转化为任意命令执行。 + +## 运行边界 + +`ecc-agent-rpc` 仅暴露已注册的 typed RPC 方法。它不接收自然语言计划、不选择 +优化参数,也不执行调用方提供的任意 shell 命令。候选操作仍由 Agent runtime 的 +参数校验、workspace 边界和执行回执约束。 + +启动 `AgentRuntimeServer` 时会调用 [`runtime_env.py`](runtime_env.py) 处理可选的 +打包 Sizer 运行时路径;这只准备运行环境,不代表一次 physical-design flow 已 +执行成功。实际客户端应先使用 `rpc.hello` 协商能力,并保留 operation 事件与 +workspace 产物作为执行证据。 + +## 维护约定 + +- 新增 Agent RPC 方法时,同时更新 `methods.py` 的 `AGENT_RUNTIME_METHODS`、 + 请求模型、workspace API 和对应测试;`rpc_server.py` 通常无需修改。 +- 修改 stdio framing 或通用 runtime 行为时,应修改 `chipcompiler/runtime/` 并 + 评估 `ecc rpc serve --stdio` 与 `ecc-agent-rpc` 两个入口。 +- 直接调试可运行 `python -m agent.rpc_server`,但输入必须是合法的 + `Content-Length` JSON-RPC 帧;普通命令行参数不会被解析为 RPC 请求。 From 54d0536346bd8a837fc6a75db3094db3d843116a Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Tue, 8 Sep 2026 23:21:20 +0800 Subject: [PATCH 79/90] feat: give isolated candidates their own concurrent execution lifecycle Candidate reruns and resumes now register their runtime operation under the cloned candidate's own identity (::candidate::) instead of the source workspace id, and split into two phases: the parent snapshot (preflight + clone / load + verify) runs briefly under the source mutation lock and refuses while the source workspace owns an active operation, while the long EDA execution runs in the isolated candidate workspace without holding the source lock. Sibling candidates therefore execute concurrently, plain source operations keep their exclusive active-operation slot, and the RPC error text keeps mapping to the agent's busy-defer contract. Step stdio redirection takes the shared redirect lock so concurrent candidate steps cannot interleave the fd dup2/rebind; tool subprocesses still inherit correctly routed fds. The agent-side adapter stops refusing a second concurrent start and accepts the derived candidate operation identity while still rejecting foreign workspaces and deferring on a busy source workspace. --- agent/candidate_resume.py | 23 ++-- agent/engine.py | 10 +- agent/test/test_workspace_api.py | 205 +++++++++++++++++++++++++++++++ agent/workspace_api.py | 72 ++++++++--- 4 files changed, 285 insertions(+), 25 deletions(-) diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py index 74b94169c..b67d4cfe4 100644 --- a/agent/candidate_resume.py +++ b/agent/candidate_resume.py @@ -32,24 +32,25 @@ _required_file_sha256, _run_candidate_step, _workspace_state_sha256, + candidate_operation_workspace_id, ) def candidate_resume(api, request: CandidateResumeRequest) -> dict: _validate_candidate_resume_request(request) - api.ecc_api._get_session(request.workspace_id) + session = api.ecc_api._get_session(request.workspace_id) + api._reject_active_source_operation(request.workspace_id) try: return api.ecc_api.operations.start( - workspace_id=request.workspace_id, + workspace_id=candidate_operation_workspace_id( + request.workspace_id, request.candidate_id + ), kind="candidate_resume", origin="agent", rerun=True, step="Harden", idempotency_key=request.idempotency_key, - runner=lambda observer: api._with_workspace_lock( - request.workspace_id, - lambda session: _candidate_resume(api, session, request, observer), - ), + runner=lambda observer: _candidate_resume(api, session, request, observer), ) except RuntimeOperationConflict as exc: raise RuntimeApiError("command_failed", str(exc)) from exc @@ -61,8 +62,14 @@ def _candidate_resume(api, session, request: CandidateResumeRequest, observer) - resume_step = None evidence_ready = False try: - candidate_workspace, manifest, parent = _load_candidate_resume( - api.ecc_api, session.workspace, request.candidate_id + # Snapshot phase: load and verify the existing candidate under the + # source mutation lock; execution then proceeds in the isolated + # candidate workspace without holding the source lock. + candidate_workspace, manifest, parent = api._with_workspace_lock( + request.workspace_id, + lambda locked: _load_candidate_resume( + api.ecc_api, locked.workspace, request.candidate_id + ), ) flow = api._build_flow(candidate_workspace, create_step_workspaces=False) create_step_workspaces = getattr(flow, "create_step_workspaces", None) diff --git a/agent/engine.py b/agent/engine.py index 4cbe44484..15ec9f0e7 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -10,7 +10,7 @@ _wait_for_step_rendered, ) from chipcompiler.engine.step_execution import get_process_rss_mb, track_current_process_memory -from chipcompiler.utility.log import redirect_stdio_to_file +from chipcompiler.utility.log import redirect_stdio_to_file, stdio_redirect_lock from .plot import _is_candidate_workspace from .sta_parallel import track_sta_process_memory @@ -102,7 +102,13 @@ def _redirect_step_stdio(self, workspace_step: WorkspaceStep) -> None: try: log_file = os.path.abspath(log_file) os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) - redirect_stdio_to_file(log_file) + # ponytail: fd-level redirect is process-global; the lock only + # keeps the dup2+rebind atomic across concurrent candidate steps. + # A parent-process print during an overlap may still land in the + # other candidate's log; step tools inherit fds at spawn, so + # per-candidate tool logs stay correctly routed. + with stdio_redirect_lock: + redirect_stdio_to_file(log_file) except Exception: traceback.print_exc() diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index d771c469a..f1107bd83 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -1048,3 +1048,208 @@ def _wait_for_terminal(operations, operation_id, expected_state="succeeded"): return status deadline.wait(0.01) raise AssertionError("candidate operation did not reach a terminal state") + + +def _seed_candidate_source_workspace(tmp_path: Path) -> tuple[Path, Path, object]: + flow_data = { + "steps": [ + {"name": "Floorplan", "tool": "ecc", "state": "Success"}, + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Success"}, + {"name": "Harden", "tool": "ecc", "state": "Success"}, + ] + } + flow_path = tmp_path / "home" / "flow.json" + flow_path.parent.mkdir() + flow_path.write_text(json.dumps(flow_data), encoding="utf-8") + config_path = tmp_path / "config" / "dreamplace.json" + config_path.parent.mkdir() + config_path.write_text('{"target_density": 0.5}\n', encoding="utf-8") + for directory in ( + tmp_path / "place_dreamplace" / "output", + tmp_path / "CTS_ecc" / "output", + tmp_path / "Harden_ecc" / "output", + ): + directory.mkdir(parents=True) + (directory / "stale").write_text("stale", encoding="utf-8") + workspace = SimpleNamespace( + directory=tmp_path, + flow=SimpleNamespace(data=flow_data, path=flow_path), + ) + return flow_path, config_path, workspace + + +def _fake_candidate_flow_factory(flows: list): + def build_flow(candidate_workspace, *, create_step_workspaces=True): + assert create_step_workspaces is False + root = Path(candidate_workspace.directory) + flow = _Flow( + candidate_workspace, + ( + SimpleNamespace(name="Floorplan", tool="ecc", output={}), + SimpleNamespace( + name="place", + tool="dreamplace", + output=EccOutput(dir=root / "place_dreamplace" / "output"), + analysis={"dir": root / "place_dreamplace" / "analysis"}, + ), + SimpleNamespace( + name="CTS", + tool="ecc", + output={"dir": root / "CTS_ecc" / "output"}, + ), + SimpleNamespace( + name="Harden", + tool="ecc", + output=EccOutput( + dir=root / "Harden_ecc" / "output", + gds=root / "Harden_ecc" / "output" / "gcd_Harden.gds", + lef=root / "Harden_ecc" / "output" / "gcd_Harden.lef", + lib=root / "Harden_ecc" / "output" / "gcd_Harden.lib", + ), + ), + ), + ) + flows.append(flow) + return flow + + return build_flow + + +def test_two_isolated_candidates_execute_concurrently_without_touching_the_source( + monkeypatch, tmp_path +): + flow_path, config_path, workspace = _seed_candidate_source_workspace(tmp_path) + parent_flow_bytes = flow_path.read_bytes() + api = FlowAgentRuntimeApi(_EccApi(workspace)) + flows = [] + monkeypatch.setattr( + "agent.workspace_api.build_agent_flow_for_workspace", + _fake_candidate_flow_factory(flows), + ) + monkeypatch.setattr( + "agent.workspace_api.bind_candidate_input", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "agent.workspace_api.materialize_candidate_config", + lambda candidate_workspace, _target, patch, _candidate: ( + Path(candidate_workspace.directory) / "config" / "dreamplace.json" + ).write_text( + json.dumps( + { + "random_seed": 17, + patch[0]["knob_id"].removeprefix("place."): patch[0]["value"], + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ), + ) + monkeypatch.setattr( + "agent.workspace_api.validate_candidate_step_contract", + lambda _ws, _target: "candidate", + ) + monkeypatch.setattr( + "agent.workspace_api.reapply_candidate_input_binding", + lambda *_args, **_kwargs: None, + ) + executing_roots = [] + step_barrier = threading.Barrier(2, timeout=30) + + def run_candidate_step(flow, step, *, observer): + if step.name == "Harden": + # Both candidates must be inside step execution at the same + # moment; a serialized backend times the barrier out and fails + # both operations instead of passing silently. + step_barrier.wait() + executing_roots.append(Path(flow.workspace.directory).name) + for artifact in (step.output.gds, step.output.lef, step.output.lib): + Path(artifact).write_text(step.name, encoding="utf-8") + + monkeypatch.setattr("agent.workspace_api._run_candidate_step", run_candidate_step) + + first = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + second = api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-2", + patch=[{"knob_id": "place.routability_opt", "value": True}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-2", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + assert first["operationId"] != second["operationId"] + # Isolated candidate operations never occupy the source workspace slot, + # so plain source operations stay available while candidates execute. + assert api.ecc_api.operations.workspace_snapshot("workspace-1")["operations"] == [] + _wait_for_terminal(api.ecc_api.operations, first["operationId"]) + _wait_for_terminal(api.ecc_api.operations, second["operationId"]) + assert sorted(executing_roots) == ["candidate-1", "candidate-2"] + for candidate in ("candidate-1", "candidate-2"): + candidate_root = tmp_path / ".agent" / "candidates" / candidate + assert (candidate_root / "analysis" / "candidate_workspace.v1.json").is_file() + assert (candidate_root / "Harden_ecc" / "output" / "gcd_Harden.gds").is_file() + assert flow_path.read_bytes() == parent_flow_bytes + assert config_path.read_text(encoding="utf-8") == '{"target_density": 0.5}\n' + assert (tmp_path / "place_dreamplace" / "output" / "stale").is_file() + + +def test_candidate_snapshot_refuses_while_a_source_operation_is_active(tmp_path): + flow_path, _config_path, workspace = _seed_candidate_source_workspace(tmp_path) + api = FlowAgentRuntimeApi(_EccApi(workspace)) + release = threading.Event() + + def source_runner(observer): + release.wait(30) + return {} + + source = api.ecc_api.operations.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="gui-flow-1", + runner=source_runner, + ) + try: + with pytest.raises(RuntimeApiError) as excinfo: + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="Harden", + candidate_id="candidate-1", + patch=[{"knob_id": "place.target_density", "value": 0.6}], + execution_scope="full_flow", + idempotency_key="episode-1.intervention-1", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + assert "already has an active operation" in str(excinfo.value) + assert not (tmp_path / ".agent" / "candidates" / "candidate-1").exists() + finally: + release.set() + _wait_for_terminal(api.ecc_api.operations, source["operationId"]) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 086631b4f..a491f3f43 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -54,6 +54,17 @@ def _stable_hash(value) -> str: return f"sha256:{sha256(payload).hexdigest()}" +def candidate_operation_workspace_id(workspace_id: str, candidate_id: str) -> str: + """Stable operation identity of one isolated candidate workspace. + + Candidate reruns register their operation under the cloned candidate's own + identity instead of the source workspace id, so sibling candidates get + independent execution lifecycles while the source workspace keeps its + exclusive active-operation slot for real source mutations. + """ + return f"{workspace_id}::candidate::{candidate_id}" + + def _parameter_unit(knob_id: str) -> str: if knob_id.endswith("routability_opt"): return "boolean" @@ -140,19 +151,19 @@ def materialize_candidate(self, request: CandidateMaterializeRequest) -> dict: def candidate_rerun(self, request: CandidateRerunRequest) -> dict: _validate_candidate_rerun_request(request) - self.ecc_api._get_session(request.workspace_id) + session = self.ecc_api._get_session(request.workspace_id) + self._reject_active_source_operation(request.workspace_id) try: return self.ecc_api.operations.start( - workspace_id=request.workspace_id, + workspace_id=candidate_operation_workspace_id( + request.workspace_id, request.candidate_id + ), kind="candidate_rerun", origin="agent", rerun=True, step=request.target_step, idempotency_key=request.idempotency_key, - runner=lambda observer: self._with_workspace_lock( - request.workspace_id, - lambda session: self._candidate_rerun(session, request, observer), - ), + runner=lambda observer: self._candidate_rerun(session, request, observer), ) except RuntimeOperationConflict as exc: raise RuntimeApiError("command_failed", str(exc)) from exc @@ -168,16 +179,17 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> parent = None flow = None try: - preflight_done = self._preflight_candidate_rerun_before_clone( - session.workspace, request - ) - candidate_workspace, candidate_root_ref, parent = _create_candidate_workspace( - self.ecc_api, - session.workspace, - request.candidate_id, - request.parent_candidate_root_ref, - request.target_step, + # Snapshot phase: preflight and clone the verified parent under the + # source mutation lock so the parent cannot mutate mid-copy. + preflight_done, candidate_workspace, candidate_root_ref, parent = ( + self._with_workspace_lock( + request.workspace_id, + lambda locked: self._clone_candidate_snapshot(locked, request), + ) ) + # Execution phase: the clone owns an isolated lifecycle and never + # holds the source lock, so sibling candidates and source + # operations can run while these steps execute. flow = self._build_flow(candidate_workspace, create_step_workspaces=False) create_step_workspaces = getattr(flow, "create_step_workspaces", None) if callable(create_step_workspaces): @@ -231,6 +243,36 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> if flow is not None: self.ecc_api._close_transient_flow_db(flow) + def _clone_candidate_snapshot(self, session, request: CandidateRerunRequest): + preflight_done = self._preflight_candidate_rerun_before_clone(session.workspace, request) + cloned = _create_candidate_workspace( + self.ecc_api, + session.workspace, + request.candidate_id, + request.parent_candidate_root_ref, + request.target_step, + ) + return (preflight_done, *cloned) + + def _reject_active_source_operation(self, workspace_id: str) -> None: + """Keep parent snapshot preparation exclusive with source operations. + + Isolated candidates execute concurrently under their own operation + identity, but cloning a verified parent refuses while the source + workspace owns an active operation; the agent retries the same + idempotent start once the source is idle. + """ + operations = self.ecc_api.operations.workspace_snapshot(workspace_id)["operations"] + active = next( + (operation for operation in operations if operation.get("shutdownBarrier")), + None, + ) + if active is not None: + raise RuntimeApiError( + "command_failed", + f"workspace already has an active operation: {active.get('operationId', '')}", + ) + def _build_flow(self, workspace, *, create_step_workspaces: bool = True): try: flow = build_agent_flow_for_workspace( From 111d2150ff397a3f15c2cc9daa9348e48ab36b8c Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 9 Sep 2026 10:21:48 +0800 Subject: [PATCH 80/90] fix: adopt the canonical rtl2gds flow after the main sync --- agent/test/test_workspace_api.py | 8 ++------ agent/workspace_api.py | 2 +- test/runtime/test_workspace_api.py | 4 ---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index f1107bd83..f950e392f 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -213,7 +213,7 @@ def test_floorplan_candidate_uses_synthesis_checkpoint_across_lec() -> None: assert _candidate_source_step(flow, "Floorplan") == "Synthesis" -def test_agent_flow_defaults_to_harden_flow(monkeypatch): +def test_agent_flow_defaults_to_full_rtl2gds_flow(monkeypatch): class RecordingFlow: def __init__(self, workspace): self.workspace = workspace @@ -233,14 +233,10 @@ def create_step_workspaces(self): "chipcompiler.rtl2gds.build_rtl2gds_flow", lambda: [("rtl2gds", "ecc", "Unstart")], ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_harden_flow", - lambda: [("Harden", "ecc", "Unstart")], - ) flow = build_agent_flow_for_workspace(SimpleNamespace()) - assert flow.added_steps == [("Harden", "ecc", "Unstart")] + assert flow.added_steps == [("rtl2gds", "ecc", "Unstart")] def test_candidate_rerun_starts_a_full_flow_operation_and_replays_its_receipts( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index a491f3f43..8624455fb 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -82,7 +82,7 @@ def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = flow = AgentEngineFlow(workspace=workspace) if not flow.has_init(): - for step, tool, state in rtl2gds_api.build_harden_flow(): + for step, tool, state in rtl2gds_api.build_rtl2gds_flow(): flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: flow.create_step_workspaces() diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index a108cb1b0..3a74a19f4 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -228,10 +228,6 @@ def test_runtime_workspace_defaults_to_rtl2gds_flow(monkeypatch): "chipcompiler.rtl2gds.build_rtl2gds_flow", lambda: [("rtl2gds", "ecc", "Unstart")], ) - monkeypatch.setattr( - "chipcompiler.rtl2gds.build_harden_flow", - lambda: [("Harden", "ecc", "Unstart")], - ) flow = build_flow_for_workspace(workspace) From b7d36aa7e9a02357ebfa42675d42eb58830b6219 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 9 Sep 2026 19:08:35 +0800 Subject: [PATCH 81/90] feat: run candidate step execution in an isolated worker process The ECC C++ tools keep process-global config and log state, so two candidates executing inside one rpc-server process cross-write logs and leave steps Incomplete. Each candidate rerun/resume now executes its step loop in a dedicated worker subprocess; the parent keeps the operation lifecycle and replays step.started events from the worker result. Unit tests drive the in-process path via ECC_CANDIDATE_STEP_ISOLATION=0; isolated execution falls back in-process when no interpreter is available. --- agent/candidate_resume.py | 5 +- agent/candidate_worker.py | 169 ++++++++++++++++++++++++++++ agent/test/conftest.py | 5 + agent/test/test_candidate_resume.py | 2 +- agent/workspace_api.py | 4 +- 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 agent/candidate_worker.py create mode 100644 agent/test/conftest.py diff --git a/agent/candidate_resume.py b/agent/candidate_resume.py index b67d4cfe4..97faccd36 100644 --- a/agent/candidate_resume.py +++ b/agent/candidate_resume.py @@ -30,9 +30,9 @@ _prepare_candidate_rerun, _reapply_candidate_input, _required_file_sha256, - _run_candidate_step, _workspace_state_sha256, candidate_operation_workspace_id, + run_candidate_steps_isolated, ) @@ -82,8 +82,7 @@ def _candidate_resume(api, session, request: CandidateResumeRequest, observer) - evidence_ready = True _prepare_candidate_rerun(candidate_workspace, flow, steps) _notify_candidate_resume_prepared(observer, steps, manifest["target_step"]) - for step in steps: - _run_candidate_step(flow, step, observer=observer) + run_candidate_steps_isolated(flow, steps, observer=observer) result = _candidate_rerun_result( candidate_workspace, rerun_request, diff --git a/agent/candidate_worker.py b/agent/candidate_worker.py new file mode 100644 index 000000000..7bbef3374 --- /dev/null +++ b/agent/candidate_worker.py @@ -0,0 +1,169 @@ +"""Execute candidate rerun steps in an isolated worker process. + +The ECC C++ tools (DREAMPlace, sizer) keep process-global state: config +singletons and the native log redirect. Two candidates executing inside one +rpc-server process overwrite each other's state and cross-write step logs, +leaving steps Incomplete. Running each candidate's step loop in its own +process restores isolation without touching chipcompiler. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +from chipcompiler.runtime.workspace_api import RuntimeApiError + +PAYLOAD_SCHEMA_VERSION = 1 +RESULT_SCHEMA_VERSION = 1 +_RESULT_NAME = "candidate-worker.v1.json" +_POLL_SECONDS = 1.0 + +_ISOLATION_FLAG = "ECC_CANDIDATE_STEP_ISOLATION" + + +def _state_value(state) -> str: + return getattr(state, "value", str(state)) + + +class _MarkerObserver: + """Persist the parent's runtime operation marker without RPC wiring.""" + + def __init__(self, marker): + self.runtime_operation = marker + + +def run_candidate_steps_isolated(flow, steps, *, observer) -> None: + """Run the candidate step loop in a subprocess, preserving failure shape. + + Falls back to in-process execution when isolation is disabled (unit + tests drive fake flows through the same loop) or when a worker process + cannot start, matching the legacy behavior of frozen environments. + """ + if os.environ.get(_ISOLATION_FLAG, "1") == "0": + from .workspace_api import _run_candidate_step + + for step in steps: + _run_candidate_step(flow, step, observer=observer) + return + + candidate_root = Path(flow.workspace.directory) + result_path = candidate_root / "analysis" / _RESULT_NAME + payload = { + "schema_version": PAYLOAD_SCHEMA_VERSION, + "candidate_directory": str(candidate_root), + "step_names": [str(step.name) for step in steps], + "runtime_operation": getattr(observer, "runtime_operation", None), + "result_path": str(result_path), + } + try: + process = subprocess.Popen( + [sys.executable, "-m", "agent.candidate_worker"], + cwd=str(Path(__file__).resolve().parents[1]), + stdin=subprocess.PIPE, + ) + except OSError as exc: + print( + f"[candidate-worker] isolated execution unavailable ({exc});" + " running candidate steps in process", + file=sys.stderr, + ) + from .workspace_api import _run_candidate_step + + for step in steps: + _run_candidate_step(flow, step, observer=observer) + return + + process.stdin.write(json.dumps(payload).encode("utf-8")) + process.stdin.close() + step_by_name = {str(step.name): step for step in steps} + emitted: set[str] = set() + while process.poll() is None: + _replay_step_started(result_path, step_by_name, observer, emitted) + time.sleep(_POLL_SECONDS) + _replay_step_started(result_path, step_by_name, observer, emitted) + result = _read_result(result_path) + if process.returncode != 0 or not isinstance(result, dict) or result.get("ok") is not True: + error = (result or {}).get("error") or ( + f"candidate worker exited with code {process.returncode}" + ) + raise RuntimeApiError("command_failed", str(error)) + + +def _replay_step_started(result_path: Path, step_by_name, observer, emitted: set[str]) -> None: + """Re-emit step.started for steps the worker picked up. + + The worker cannot reach the operation manager, so the parent replays + start markers from the worker result to keep operation.current_step and + the RPC event stream equivalent to in-process execution. + """ + callback = getattr(observer, "on_step_started", None) + if not callable(callback): + return + result = _read_result(result_path) + if not isinstance(result, dict): + return + for entry in result.get("steps", []): + name = entry.get("name") + if entry.get("state") == "Ongoing" and name not in emitted and name in step_by_name: + emitted.add(name) + callback(step_by_name[name]) + + +def _read_result(result_path: Path): + try: + return json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def main() -> int: + payload = json.loads(sys.stdin.read()) + result_path = Path(payload["result_path"]) + result = {"schema_version": RESULT_SCHEMA_VERSION, "ok": False, "steps": [], "error": None} + + def flush() -> None: + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text(json.dumps(result), encoding="utf-8") + + try: + import chipcompiler.data as data_api + from chipcompiler.runtime.workspace_api import _init_db_engine_for_workspace_step + + from .workspace_api import build_agent_flow_for_workspace + + workspace = data_api.load_workspace(directory=payload["candidate_directory"]) + if workspace is None: + raise RuntimeError("load workspace failed") + flow = build_agent_flow_for_workspace(workspace, create_step_workspaces=False) + # workspace_steps is populated by create_step_workspaces; dirs already + # exist from the parent's prepare phase, so only config init is skipped. + flow.create_step_workspaces(initialize_config=False) + observer = _MarkerObserver(payload.get("runtime_operation")) + for name in payload["step_names"]: + step = flow.get_workspace_step(name) + if step is None: + raise RuntimeError(f"candidate step missing: {name}") + _init_db_engine_for_workspace_step(flow, step) + result["steps"].append({"name": name, "state": "Ongoing"}) + flush() + state = _state_value(flow.run_step(step, rerun=True, observer=observer)) + result["steps"][-1]["state"] = state + flush() + if state != "Success": + result["error"] = f"candidate rerun step {name} failed with state {state}" + break + else: + result["ok"] = True + except Exception as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + flush() + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent/test/conftest.py b/agent/test/conftest.py new file mode 100644 index 000000000..7893e0892 --- /dev/null +++ b/agent/test/conftest.py @@ -0,0 +1,5 @@ +import os + +# Fake-flow tests drive step execution in process; disable the per-candidate +# worker subprocess for the unit suite. +os.environ["ECC_CANDIDATE_STEP_ISOLATION"] = "0" diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py index 098fc0eb5..224b7d610 100644 --- a/agent/test/test_candidate_resume.py +++ b/agent/test/test_candidate_resume.py @@ -106,7 +106,7 @@ def test_candidate_resume_runs_in_place_and_preserves_successful_target_artifact ) run_steps = [] monkeypatch.setattr( - "agent.candidate_resume._run_candidate_step", + "agent.workspace_api._run_candidate_step", lambda _flow, step, **_kwargs: run_steps.append(step.name), ) monkeypatch.setattr( diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 8624455fb..845cb1d1c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -17,6 +17,7 @@ from chipcompiler.utility.path import path_is_within from .candidate_clone import candidate_clone_ignore +from .candidate_worker import run_candidate_steps_isolated from .data import ( FoundationExtractor, bind_candidate_input, @@ -210,8 +211,7 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> _reapply_candidate_input(candidate_workspace, flow, request.target_step) else: reapply_candidate_input_binding(candidate_workspace, flow, request.target_step) - for step in steps: - _run_candidate_step(flow, step, observer=observer) + run_candidate_steps_isolated(flow, steps, observer=observer) return _candidate_rerun_result( candidate_workspace, request, From 51ebeec8573fe9ff1818b05aa662fc2a09627cd2 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 10 Sep 2026 00:27:23 +0800 Subject: [PATCH 82/90] fix: report target_density effective once the density operator consumes the configured value --- agent/data/parameter_runtime_observer.py | 16 ++++------------ agent/test/test_parameter_receipt_artifacts.py | 2 +- agent/test/test_parameter_runtime_observer.py | 16 ++++++++++++++++ agent/test/test_tools.py | 2 +- agent/test/test_workspace_api.py | 2 +- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/agent/data/parameter_runtime_observer.py b/agent/data/parameter_runtime_observer.py index e375c0f90..c7a24c270 100644 --- a/agent/data/parameter_runtime_observer.py +++ b/agent/data/parameter_runtime_observer.py @@ -12,7 +12,7 @@ from .candidate_artifacts import sha256_path, write_json_atomic from .observed_callable import ObservedCallable -DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v2" +DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v3" RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v2.json" DREAMPLACE_KNOBS = frozenset( { @@ -267,9 +267,9 @@ def _build_dreamplace_report(patch, engine, ppa, probe, *, engine_succeeded): } observation["density_operator_call_count"] = probe.get("density_operator_call_count", 0) value = observation["target_density"] - if observation["density_operator_call_count"] > 0 and _same_number( - value, observation["density_tensor_value"] - ): + # The density tensor ramps adaptively toward the configured target, so + # its live value tracks placement progress, not the parameter state. + if observation["density_operator_call_count"] > 0 and value is not None: actual, status, reason = value, "effective", None elif knob_id == "place.target_overflow": threshold = _scalar_value(getattr(params, "stop_overflow", None)) @@ -347,11 +347,3 @@ def _scalar_value(value): if type(value) in {bool, int}: return value return value if type(value) is float and math.isfinite(value) else None - - -def _same_number(left, right): - return ( - type(left) in {int, float} - and type(right) in {int, float} - and math.isclose(left, right, rel_tol=1e-6, abs_tol=1e-7) - ) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 8f36763bd..b809ea183 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -23,7 +23,7 @@ PRODUCER = Path(__file__).parents[1] / "data/parameter_runtime_observer.py" TOOL = { "name": "DREAMPlace", - "revision": "ecc.agent.dreamplace_parameter_observer.v2", + "revision": "ecc.agent.dreamplace_parameter_observer.v3", "source_sha256": sha256_path(PRODUCER), } diff --git a/agent/test/test_parameter_runtime_observer.py b/agent/test/test_parameter_runtime_observer.py index 98d9a7cba..7fb7ff171 100644 --- a/agent/test/test_parameter_runtime_observer.py +++ b/agent/test/test_parameter_runtime_observer.py @@ -50,6 +50,22 @@ def test_configured_density_without_consumer_is_unknown(): assert (report["status"], report["actual_value"]) == ("unknown", None) +def test_adaptive_density_tensor_does_not_revoke_effectiveness(): + report = _report( + "place.target_density", + 0.2, + {}, + { + "target_density": 0.55, + "density_tensor_value": 0.709, + "density_operator_call_count": 569, + "utilization_floor": 0.5196, + }, + ) + assert (report["status"], report["actual_value"]) == ("effective", 0.55) + assert report["observation"]["density_tensor_value"] == 0.709 + + def test_completed_placement_preserves_overflow_before_later_failure(): recorder = DreamplaceRecorder({"knob_id": "place.target_overflow", "value": 0.1}) engine = SimpleNamespace(params=SimpleNamespace(stop_overflow=0.1)) diff --git a/agent/test/test_tools.py b/agent/test/test_tools.py index e505fa9e8..4c494fe85 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -171,7 +171,7 @@ def capture(_patch): assert eda.run_step(workspace, step, ecc_module=True) is True report = json.loads((tmp_path / "analysis" / "parameter_runtime_report.v2.json").read_text()) - assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v2" + assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v3" assert report["status"] == "effective" assert report["observation"]["density_operator_call_count"] == 2 diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index f950e392f..b62aa28bb 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -573,7 +573,7 @@ def test_failed_candidate_returns_materialization_application_and_manifest_evide monkeypatch.setattr("agent.workspace_api._reapply_candidate_input", lambda *_args: None) tool = { "name": "DREAMPlace", - "revision": "ecc.agent.dreamplace_parameter_observer.v2", + "revision": "ecc.agent.dreamplace_parameter_observer.v3", "source_sha256": "sha256:" + "3" * 64, } From a59b2b6c717abed30eae536acdcd6d9e769ea858 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Thu, 10 Sep 2026 10:23:56 +0800 Subject: [PATCH 83/90] fix: keep public wire names in lec normalize for equiv_make cut-points normalize_design ran opt_clean -purge, stripping internal public net names so equiv_make only matched top-level ports and equiv_induct lacked internal cut-points, producing mass unproven results on functionally equivalent netlists. Drop -purge on the normalize side; the equiv-side purge stays. Same root cause and fix as upstream a6217b9a (ecc#273). --- chipcompiler/tools/yosys_lec/scripts/run_lec.tcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chipcompiler/tools/yosys_lec/scripts/run_lec.tcl b/chipcompiler/tools/yosys_lec/scripts/run_lec.tcl index ea9175e99..c5e9dd8aa 100644 --- a/chipcompiler/tools/yosys_lec/scripts/run_lec.tcl +++ b/chipcompiler/tools/yosys_lec/scripts/run_lec.tcl @@ -29,7 +29,7 @@ proc normalize_design {top_design} { yosys async2sync yosys flatten yosys splitnets -ports -format _ - yosys opt_clean -purge + yosys opt_clean } proc build_design {stash_name top_design netlist_file} { From 3a0c96c630af2e9a07884ac264ae91a1173c1151 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:36:09 +0000 Subject: [PATCH 84/90] fix: address review feedback in receipt context and test import Co-authored-by: YihangQiu <65992277+YihangQiu@users.noreply.github.com> --- agent/test/test_parameter_receipt_artifacts.py | 2 -- agent/workspace_api.py | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/agent/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py index 8f36763bd..2ce303e54 100644 --- a/agent/test/test_parameter_receipt_artifacts.py +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json from pathlib import Path from types import SimpleNamespace diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 8624455fb..78c1f167c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -865,11 +865,15 @@ def _parameter_receipt_context(workspace, request, parent_flow_sha256: str) -> d raise RuntimeApiError("command_failed", "candidate input fingerprints are unavailable") try: tech_lef = Path(getattr(getattr(workspace, "pdk", None), "tech", None)) + site_core = getattr(getattr(workspace, "pdk", None), "site_core", None) + if not isinstance(site_core, str) or not site_core.strip(): + raise ValueError("core site is unavailable") + site_pattern = re.escape(site_core.strip()) pdk_sha256 = f"sha256:{sha256(tech_lef.read_bytes()).hexdigest()}" lef_text = tech_lef.read_text(encoding="utf-8") units_match = re.search(r"DATABASE\s+MICRONS\s+(\d+)", lef_text, re.IGNORECASE) site_match = re.search( - r"SITE\s+(?:core7|CoreSite)\b(?P.*?)END\s+(?:core7|CoreSite)", + rf"SITE\s+{site_pattern}\b(?P.*?)END\s+{site_pattern}", lef_text, re.IGNORECASE | re.DOTALL, ) From 1361af2b1ae98220af3e0e0075b0511e98b9cd80 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Sun, 13 Sep 2026 17:37:36 +0800 Subject: [PATCH 85/90] feat: enable full-corner parallel STA for every workspace sta step sta_workers() gated parallel corners on candidate workspaces only, so GUI flows ran the sta step serially while optimization candidates ran in parallel. The corner snapshot mechanism is a generic ECC facility (each worker loads its own file snapshot), so drop the workspace gate and keep the tool/step, worker-count, and platform checks. ECOS_AGENT_STA_WORKERS (default 2 on Linux) now applies to every agent-runtime sta step. --- agent/sta_parallel.py | 13 ++++++++----- agent/test/test_sta_parallel.py | 21 ++++++++------------- agent/tools.py | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/agent/sta_parallel.py b/agent/sta_parallel.py index fda558d9b..bdf4c4237 100644 --- a/agent/sta_parallel.py +++ b/agent/sta_parallel.py @@ -1,4 +1,9 @@ -"""Candidate-only full-corner STA with isolated native processes.""" +"""Full-corner STA with isolated native processes. + +Parallel STA applies to every workspace's `sta` step (GUI flows and +optimization candidates alike); `ECOS_AGENT_STA_WORKERS` selects the worker +count, defaulting to 2 on Linux and 1 elsewhere. +""" import multiprocessing import os @@ -18,11 +23,9 @@ from chipcompiler.tools.ecc.sta_qor import sta_artifact_directory from chipcompiler.utility.log import redirect_stdio_to_file -from .plot import _is_candidate_workspace - -def sta_workers(workspace, step) -> int: - if step.tool != "ecc" or step.name != "sta" or not _is_candidate_workspace(workspace): +def sta_workers(step) -> int: + if step.tool != "ecc" or step.name != "sta": return 1 value = os.environ.get("ECOS_AGENT_STA_WORKERS", "2" if sys.platform == "linux" else "1") if value not in {"1", "2", "4"}: diff --git a/agent/test/test_sta_parallel.py b/agent/test/test_sta_parallel.py index e5e2c7231..3769b7f8f 100644 --- a/agent/test/test_sta_parallel.py +++ b/agent/test/test_sta_parallel.py @@ -123,33 +123,28 @@ def _workspace(tmp_path): ) -def test_worker_setting_is_candidate_sta_only(tmp_path, monkeypatch): +def test_worker_setting_applies_to_every_workspace_sta_step(tmp_path, monkeypatch): monkeypatch.setattr(sta.sys, "platform", "linux") - workspace = _workspace(tmp_path) step = SimpleNamespace(tool="ecc", name="sta") - assert sta.sta_workers(workspace, step) == 2 + assert sta.sta_workers(step) == 2 for value in ("1", "2", "4"): monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", value) - assert sta.sta_workers(workspace, step) == int(value) + assert sta.sta_workers(step) == int(value) monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", "13") with pytest.raises(ValueError, match="1, 2, or 4"): - sta.sta_workers(workspace, step) + sta.sta_workers(step) step.name = "Harden" - assert sta.sta_workers(workspace, step) == 1 + assert sta.sta_workers(step) == 1 -def test_non_linux_candidates_keep_serial_default(tmp_path, monkeypatch): +def test_non_linux_keeps_serial_default(tmp_path, monkeypatch): monkeypatch.setattr(sta.sys, "platform", "darwin") monkeypatch.delenv("ECOS_AGENT_STA_WORKERS", raising=False) - workspace = _workspace(tmp_path) step = SimpleNamespace(tool="ecc", name="sta") - assert sta.sta_workers(workspace, step) == 1 + assert sta.sta_workers(step) == 1 monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", "2") with pytest.raises(ValueError, match="requires Linux"): - sta.sta_workers(workspace, step) - step.name = "sta" - workspace.directory = tmp_path / "ordinary" - assert sta.sta_workers(workspace, step) == 1 + sta.sta_workers(step) @pytest.mark.parametrize("fail", [False, True]) diff --git a/agent/tools.py b/agent/tools.py index 5d7092500..ee2c71f63 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -22,7 +22,7 @@ def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool log_workspace_step(step, workspace.logger) def run_tool(): - workers = sta_workers(workspace, step) + workers = sta_workers(step) if workers > 1: return run_parallel_sta(workspace, step, ecc_module, workers) if step.tool != "sizer": From 5ec560814726b6d8e65ac9933f5e3acede448588 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Tue, 15 Sep 2026 14:54:56 +0800 Subject: [PATCH 86/90] chore: fast-forward tool submodules to pair with upstream macro placement - ecc-dreamplace efbca335..er 77e343ca (v0.1.0-alpha.6-10): macro-only placement writeback; requires the ECC-side macro placement parameter plumbing arriving with the upstream main merge. - ecc-tools efbca335..fdaa70a97 (v0.1.0-alpha.13-269): requires Boost 1.92.0 exactly (built against /home/yhqiu1/.deps/boost-1.92.0, the upstream CI recipe). --- chipcompiler/thirdparty/ecc-dreamplace | 2 +- chipcompiler/thirdparty/ecc-tools | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/chipcompiler/thirdparty/ecc-dreamplace b/chipcompiler/thirdparty/ecc-dreamplace index 308dcd35d..77e343ca9 160000 --- a/chipcompiler/thirdparty/ecc-dreamplace +++ b/chipcompiler/thirdparty/ecc-dreamplace @@ -1 +1 @@ -Subproject commit 308dcd35da567d56301fcbb3a6b22018a2d4fec4 +Subproject commit 77e343ca9eab9e79b429ca1a4cb82d9c328fa9c3 diff --git a/chipcompiler/thirdparty/ecc-tools b/chipcompiler/thirdparty/ecc-tools index efbca3356..fdaa70a97 160000 --- a/chipcompiler/thirdparty/ecc-tools +++ b/chipcompiler/thirdparty/ecc-tools @@ -1 +1 @@ -Subproject commit efbca335661a1547202abbde40855441da7766dd +Subproject commit fdaa70a971be6c33a8dc6d695011bd9854ee18c5 From ccdd83be68f2e6f731756a9d38c3f0f67e428c10 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Tue, 15 Sep 2026 23:02:26 +0800 Subject: [PATCH 87/90] fix: align candidate rerun bindings with the split floorplan topology The candidate input-edge allowlist still assumed the single-step Floorplan topology, so every isolated candidate rerun failed at bind_candidate_input with 'unsupported candidate input edge: postFloorplan -> place' before any step could execute, leaving candidate workspaces without analysis artifacts and forcing every optimization outcome to evidence_invalid. Declare the current canonical edges for the preFloorplan -> macroPlacement -> postFloorplan phase split (and the tail steps it introduced), resolve the RPC-level Floorplan target to the sub-step range with the Synthesis checkpoint as its source, and cover the current topology with regression tests. --- agent/data/candidate_input_binding.py | 18 +++++++++++ agent/test/test_workspace_api.py | 45 +++++++++++++++++++++++++++ agent/workspace_api.py | 18 ++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/agent/data/candidate_input_binding.py b/agent/data/candidate_input_binding.py index 02b505658..13d6b30e1 100644 --- a/agent/data/candidate_input_binding.py +++ b/agent/data/candidate_input_binding.py @@ -19,8 +19,26 @@ INPUT_BINDING_FILENAME = "candidate_input_binding.v1.json" CANONICAL_INPUT_EDGES = frozenset( { + # Current flow topology: the floorplan phase runs as the sub-steps + # preFloorplan -> macroPlacement -> postFloorplan sharing the + # "Floorplan" configuration. + ("preFloorplan", "Synthesis"), + ("macroPlacement", "preFloorplan"), + ("postFloorplan", "macroPlacement"), + ("place", "postFloorplan"), + ("Timing optimization", "legalization"), + ("route", "Timing optimization"), + ("filler", "route"), + ("RCX", "filler"), + ("sta", "RCX"), + ("lvs", "sta"), + ("postRouteLec", "lvs"), + ("drc", "postRouteLec"), + ("Harden", "drc"), + # RPC-level floorplan target binding (see prepare_floorplan_mode). ("Floorplan", "initial"), ("Floorplan", "Synthesis"), + # Legacy single-step floorplan topology and sanctioned resume edges. ("place", "Floorplan"), ("CTS", "place"), ("legalization", "CTS"), diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index b62aa28bb..7c6272cc8 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -6,6 +6,7 @@ import pytest from agent.data.candidate_artifacts import sha256_path +from agent.data.candidate_input_binding import _validate_edge from agent.data.candidate_materialization import materialize_candidate_config from agent.requests import CandidateRerunRequest from agent.workspace_api import ( @@ -14,6 +15,7 @@ _candidate_rerun_steps, _candidate_source_step, _candidate_step_artifact_dirs, + _candidate_step_range, _create_candidate_workspace, _materialize_candidate_rerun, _preflight_candidate_steps, @@ -213,6 +215,49 @@ def test_floorplan_candidate_uses_synthesis_checkpoint_across_lec() -> None: assert _candidate_source_step(flow, "Floorplan") == "Synthesis" +_CURRENT_FLOW_STEPS = [ + {"name": "Synthesis", "tool": "yosys"}, + {"name": "preFloorplan", "tool": "ecc"}, + {"name": "macroPlacement", "tool": "dreamplace"}, + {"name": "postFloorplan", "tool": "ecc"}, + {"name": "place", "tool": "dreamplace"}, + {"name": "CTS", "tool": "ecc"}, + {"name": "legalization", "tool": "dreamplace"}, + {"name": "Timing optimization", "tool": "sizer"}, + {"name": "route", "tool": "ecc"}, + {"name": "filler", "tool": "ecc"}, + {"name": "RCX", "tool": "ecc"}, + {"name": "sta", "tool": "ecc"}, + {"name": "lvs", "tool": "ecc"}, + {"name": "postRouteLec", "tool": "yosys_lec"}, + {"name": "drc", "tool": "ecc"}, + {"name": "Harden", "tool": "ecc"}, +] + + +def test_place_candidate_binds_the_post_floorplan_predecessor() -> None: + flow = SimpleNamespace( + workspace=SimpleNamespace(flow=SimpleNamespace(data={"steps": _CURRENT_FLOW_STEPS})) + ) + + assert _candidate_source_step(flow, "place") == "postFloorplan" + + +def test_current_flow_topological_edges_are_declared() -> None: + for index in range(1, len(_CURRENT_FLOW_STEPS)): + target = _CURRENT_FLOW_STEPS[index]["name"] + source = _CURRENT_FLOW_STEPS[index - 1]["name"] + _validate_edge(target, source) + + +def test_floorplan_target_range_starts_at_the_first_floorplan_sub_step() -> None: + range_steps = _candidate_step_range(_CURRENT_FLOW_STEPS, "Floorplan", "Harden", "full_flow") + + assert [step["name"] for step in range_steps] == [ + step["name"] for step in _CURRENT_FLOW_STEPS[1:] + ] + + def test_agent_flow_defaults_to_full_rtl2gds_flow(monkeypatch): class RecordingFlow: def __init__(self, workspace): diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 3bda3d02d..e0d81a54b 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -330,13 +330,25 @@ def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scop ) +# The RPC-level "Floorplan" target names the shared floorplan configuration, +# not a flow step; its range starts at the first floorplan sub-step. +_FLOORPLAN_RANGE_START = {"Floorplan": "preFloorplan"} + + def _candidate_step_range( steps: list, target_step: str, end_step: str, execution_scope: str ) -> list: if execution_scope not in {"single_step", "full_flow"}: raise RuntimeApiError("invalid_request", "candidate rerun execution scope is invalid") + range_target = target_step + if target_step in _FLOORPLAN_RANGE_START and not any( + _step_value(step, "name") == target_step for step in steps + ): + # Flows running the floorplan phase as sub-steps have no literal + # "Floorplan" step; start the range at its first sub-step instead. + range_target = _FLOORPLAN_RANGE_START[target_step] target_index = next( - (index for index, step in enumerate(steps) if _step_value(step, "name") == target_step), + (index for index, step in enumerate(steps) if _step_value(step, "name") == range_target), None, ) end_index = next( @@ -992,6 +1004,10 @@ def _validate_runtime_report_binding( def _candidate_source_step(flow, target_step: str) -> str: + # The RPC-level "Floorplan" target binds the post-synthesis netlist: the + # floorplan sub-steps consume the Synthesis output as their phase input. + if target_step == "Floorplan": + return "Synthesis" steps = flow.workspace.flow.data.get("steps", []) for index, step in enumerate(steps): if step.get("name") == target_step and index: From 5c685c243c95ad4aa4c7f4a589d98d800f193dab Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 16 Sep 2026 00:16:36 +0800 Subject: [PATCH 88/90] fix: make Floorplan candidate overrides effective in die_util mode Floorplan-knob candidate reruns failed in three places that still assumed a literal Floorplan flow step: the step-range lookup, the input-binding target resolution, and the candidate-backend tool check. Resolve the RPC-level Floorplan target through a shared FLOORPLAN_TARGET_FLOW_STEP alias with a literal-name fallback. With the aliases in place the rerun exposed the real blocker: the floorplan runner re-pins '[params.die] size' with the realized die bounding box after every floorplan step, and _refresh_floorplan_config forces die_builder.mode back to die_size whenever that pin exists, so a die_util candidate was always reverted and rejected by the terminal mode validation. Treat the realized dimensions as outputs: the runner re-pins the size only in die_size workspaces, and die_util candidates drop the pin in their isolated clone before the flow loads parameters. Verified end to end on a gcd workspace clone: a floorplan.core_util 0.3 -> 0.4 candidate at die_util now runs all 16 steps, keeps the die_util mode, and realizes core_area 2381.4 -> 1796.76 um^2. --- agent/data/candidate_input_binding.py | 16 +++++++++++++-- agent/data/candidate_registry.py | 28 +++++++++++++++++++-------- agent/floorplan_mode.py | 21 ++++++++++++++++++++ agent/workspace_api.py | 16 ++++++++------- chipcompiler/tools/ecc/runner.py | 17 +++++++++++++++- 5 files changed, 80 insertions(+), 18 deletions(-) diff --git a/agent/data/candidate_input_binding.py b/agent/data/candidate_input_binding.py index 13d6b30e1..0cc908eea 100644 --- a/agent/data/candidate_input_binding.py +++ b/agent/data/candidate_input_binding.py @@ -13,6 +13,7 @@ workspace_relative_ref, write_json_atomic, ) +from .candidate_registry import FLOORPLAN_TARGET_FLOW_STEP INPUT_BINDING_SCHEMA = "ecc.workspace.candidate_input_binding.v1" INPUT_BINDING_SCHEMA_VERSION = 1 @@ -54,6 +55,17 @@ class CandidateInputBindingError(ValueError): """A candidate input binding is outside the declared physical-flow edges.""" +# The RPC-level "Floorplan" target names the shared floorplan configuration, +# not a flow step; the phase input binds on its first sub-step when the flow +# runs the split phase instead of a literal "Floorplan" step. + + +def _flow_step_name(engine_flow: Any, target_step: str) -> str: + if engine_flow.get_workspace_step(target_step) is not None: + return target_step + return FLOORPLAN_TARGET_FLOW_STEP.get(target_step, target_step) + + def bind_candidate_input( workspace: Any, engine_flow: Any, @@ -63,7 +75,7 @@ def bind_candidate_input( ) -> dict[str, Any]: candidate_id = _validated_candidate_id(candidate_id) _validate_edge(target_step, source_step) - target = _step_or_error(engine_flow, target_step, "target") + target = _step_or_error(engine_flow, _flow_step_name(engine_flow, target_step), "target") inputs = _source_inputs(workspace, engine_flow, source_step) receipt = _build_receipt(workspace, target_step, source_step, candidate_id, inputs) write_json_atomic(_receipt_path(workspace), receipt) @@ -84,7 +96,7 @@ def reapply_candidate_input_binding( return None source_step = receipt["source"]["step"] _validate_edge(target_step, source_step) - target = _step_or_error(engine_flow, target_step, "target") + target = _step_or_error(engine_flow, _flow_step_name(engine_flow, target_step), "target") inputs = _source_inputs(workspace, engine_flow, source_step) actual = _build_receipt( workspace, diff --git a/agent/data/candidate_registry.py b/agent/data/candidate_registry.py index 604a90edd..61e38c979 100644 --- a/agent/data/candidate_registry.py +++ b/agent/data/candidate_registry.py @@ -38,6 +38,12 @@ def _cts_uint(name: str, minimum: int = 1) -> CandidateKnob: return CandidateKnob(f"cts.{name}", "CTS", "CTS", (name,), "uint", minimum) +# The RPC-level "Floorplan" target names the shared floorplan configuration, +# not a flow step; the backend tool and the phase input bind on its first +# sub-step when the flow runs the split phase. +FLOORPLAN_TARGET_FLOW_STEP = {"Floorplan": "preFloorplan"} + + CANDIDATE_TARGET_BACKENDS: dict[str, CandidateTargetBackend] = { "Floorplan": CandidateTargetBackend("ecc"), "place": CandidateTargetBackend("dreamplace"), @@ -380,12 +386,18 @@ def _workspace_target_tool(workspace: Any, target_step: str) -> str | None: steps = flow_data.get("steps") if not isinstance(steps, list): return None - matches = [ - step["tool"] - for step in steps - if isinstance(step, dict) - and step.get("name") == target_step - and isinstance(step.get("tool"), str) - and step["tool"] - ] + + def tools_for(step_name: str) -> list[str]: + return [ + step["tool"] + for step in steps + if isinstance(step, dict) + and step.get("name") == step_name + and isinstance(step.get("tool"), str) + and step["tool"] + ] + + matches = tools_for(target_step) + if not matches and target_step in FLOORPLAN_TARGET_FLOW_STEP: + matches = tools_for(FLOORPLAN_TARGET_FLOW_STEP[target_step]) return matches[0] if len(matches) == 1 else None diff --git a/agent/floorplan_mode.py b/agent/floorplan_mode.py index 26fdeff9c..c50edf17f 100644 --- a/agent/floorplan_mode.py +++ b/agent/floorplan_mode.py @@ -128,6 +128,27 @@ def prepare_floorplan_mode(workspace, request) -> None: apply_floorplan_mode(workspace, "Floorplan") +def drop_pinned_die_size(workspace) -> None: + """Drop the explicit die dimensions so config refreshes keep die_util. + + ``_refresh_floorplan_config`` forces ``die_builder.mode = "die_size"`` + whenever the workspace parameters pin ``[params.die] size``; a die_util + candidate is only effective when the isolated clone stops pinning that + size. Call before the candidate flow loads its parameters. + """ + parameters = getattr(workspace, "parameters", None) + data = getattr(parameters, "data", None) + die = data.get("die") if isinstance(data, dict) else None + if not isinstance(die, dict) or not die.get("size"): + return + die.pop("size", None) + die.pop("area", None) + from chipcompiler.data.parameter import save_parameter + + if not save_parameter(parameters): + raise RuntimeApiError("command_failed", "candidate params.toml could not be updated") + + def apply_floorplan_mode(workspace, step_name: str) -> None: if step_name != "Floorplan": return diff --git a/agent/workspace_api.py b/agent/workspace_api.py index e0d81a54b..b1293cb87 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -32,10 +32,12 @@ candidate_written_patch, validate_candidate_materialization_receipt, ) +from .data.candidate_registry import FLOORPLAN_TARGET_FLOW_STEP from .data.parameter_application_receipt import build_parameter_application_receipt from .engine import AgentEngineFlow from .floorplan_mode import ( FLOORPLAN_MODE_REF, + drop_pinned_die_size, prepare_floorplan_mode, validate_floorplan_mode_request, validate_floorplan_mode_result, @@ -188,6 +190,11 @@ def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> lambda locked: self._clone_candidate_snapshot(locked, request), ) ) + # A die_util candidate must stop pinning the explicit die size + # before the flow loads its parameters, or every config refresh + # forces the mode back to die_size. + if request.floorplan_mode == "die_util": + drop_pinned_die_size(candidate_workspace) # Execution phase: the clone owns an isolated lifecycle and never # holds the source lock, so sibling candidates and source # operations can run while these steps execute. @@ -330,23 +337,18 @@ def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scop ) -# The RPC-level "Floorplan" target names the shared floorplan configuration, -# not a flow step; its range starts at the first floorplan sub-step. -_FLOORPLAN_RANGE_START = {"Floorplan": "preFloorplan"} - - def _candidate_step_range( steps: list, target_step: str, end_step: str, execution_scope: str ) -> list: if execution_scope not in {"single_step", "full_flow"}: raise RuntimeApiError("invalid_request", "candidate rerun execution scope is invalid") range_target = target_step - if target_step in _FLOORPLAN_RANGE_START and not any( + if target_step in FLOORPLAN_TARGET_FLOW_STEP and not any( _step_value(step, "name") == target_step for step in steps ): # Flows running the floorplan phase as sub-steps have no literal # "Floorplan" step; start the range at its first sub-step instead. - range_target = _FLOORPLAN_RANGE_START[target_step] + range_target = FLOORPLAN_TARGET_FLOW_STEP[target_step] target_index = next( (index for index, step in enumerate(steps) if _step_value(step, "name") == range_target), None, diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 188fcc925..0f2be1f1e 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -441,7 +441,6 @@ def save_data( aspect_ratio = die_bounding_width / die_bounding_height if die_bounding_height > 0 else 1 update_param = { - "die": {"size": [die_bounding_width, die_bounding_height], "area": die_area}, "core": { "size": [core_bounding_width, core_bounding_height], "area": core_area, @@ -452,6 +451,22 @@ def save_data( "aspect_ratio": aspect_ratio, }, } + # In die_util mode the realized die dimensions are outputs of the + # geometry solver, not inputs: re-pinning "[params.die] size" would + # make every later config refresh force die_size and invalidate the + # utilization the floorplan just consumed. Only die_size workspaces + # keep the explicit-size pin. + floorplan_mode = None + try: + floorplan_config = json_read(workspace.config[StepEnum.FLOORPLAN.value]) + floorplan_mode = (floorplan_config.get("die_builder") or {}).get("mode") + except (OSError, ValueError): + floorplan_mode = None + if floorplan_mode != "die_util": + update_param = { + "die": {"size": [die_bounding_width, die_bounding_height], "area": die_area}, + **update_param, + } update_parameters(parameters_src=update_param, parameters_target=workspace.parameters.data) if not save_parameter(workspace.parameters): From 5d4cad7d395e56864d3f0bd5b74f1f5c4465c136 Mon Sep 17 00:00:00 2001 From: YihangQiu Date: Wed, 16 Sep 2026 10:47:17 +0800 Subject: [PATCH 89/90] docs: add agent runtime README --- agent/README.md | 152 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 agent/README.md diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 000000000..d6bccebfc --- /dev/null +++ b/agent/README.md @@ -0,0 +1,152 @@ +# ECC Agent Runtime(`agent/`) + +`agent/` 是 ECC 的 **Flow Agent 运行时**:在 `chipcompiler` 标准 runtime 之上构建的 +受控、可审计的流程代理执行层。它以独立 sidecar 进程(可执行入口 +`ecc-agent-rpc`)运行,通过 JSON-RPC 2.0 over stdio 对外提供服务,供 +ECOS Studio 的 GUI 前端与 `ecos_agent` 受控优化后端驱动。 + +它只做确定性的执行与证据记录:**决策不在这里**。`agent/` 不包含任何 LLM +代码,不解析自由文本指令,也不直接执行 shell 命令;流程决策由上层 +`ecos_agent`(见 ECOS Studio 仓库)完成,每个动作以显式 schema 的 RPC 方法 +抵达本层,经参数校验与允许列表检查后执行,并留下可复核的产物与回执。 + +## 职责总览 + +- **Agent 流程适配**:为 Agent 场景定制流程引擎行为(DRC 阶段注入、观察者 + 事件流、渲染门控、内存监控、无头运行下的绘图抑制)。 +- **隔离候选执行**:为参数候选创建隔离 workspace,配置固化(materialize)、 + 上游输入绑定、独立 worker 进程重跑与断点续跑(resume),产出 hash 绑定的 + 配置/检查点回执。 +- **参数运行时观测**:对五个受控 DREAMPlace 参数与两个受控 Floorplan 参数 + 进行运行时观测,生成 hash 绑定的参数应用回执,供上层做效果归因。 +- **基础数据提取(foundation data)**:从已有 workspace 的 LEF/DEF/STA/DRC/ + 布线证据中提取带表结构契约的可审计数据表,供离线分析与建模使用。 +- **全角 STA 并行**:以隔离的原生进程并行执行全角 STA,并提供调度基准测试。 + +## 设计原则 + +1. **受控执行**:每个 RPC 方法都有 frozen dataclass 请求模型与显式校验; + 客户端按方法允许列表访问;写入 workspace 的路径一律校验不得越界。 +2. **可审计**:候选配置、输入绑定、Floorplan 模式覆盖、参数回执均以 + canonical JSON + SHA-256 摘要落盘,配置与检查点回执必须配对一致 + (candidate contract),否则拒绝执行。 +3. **可复现**:候选 workspace 从父 workspace 克隆,重跑集合、输入绑定与 + 配置覆盖全部持久化,resume 复用原候选记录且不改变历史语义。 +4. **进程隔离**:DREAMPlace 与 sizer 存在进程全局状态(配置单例、原生日志 + 重定向),同进程并发候选会互相覆写;候选的阶段循环在独立 worker 进程 + 中执行,全角 STA 同样使用隔离原生进程。 +5. **不反向依赖**:`agent/` 依赖 `chipcompiler` 的公开 runtime 接口并继承 + 扩展,不修改其行为;除观测回执的结构化输入外,不依赖 `ecos_agent`。 + +## 代码结构 + +```text +agent/ +├── rpc_server.py # 进程入口:stdin/stdout 上启动 stdio JSON-RPC 服务 +├── server.py # AgentRuntimeServer:扩展基础 RuntimeServer 与能力协商 +├── methods.py # Agent RPC 方法表(RuntimeMethodSpec 声明) +├── requests.py # RPC 请求模型(frozen dataclass + 校验) +├── workspace_api.py # AgentWorkspaceRuntimeApi / FlowAgentRuntimeApi:RPC 处理层 +├── engine.py # AgentEngineFlow:流程引擎覆盖(观察者、渲染门控、监控) +├── tools.py # Agent 侧步骤执行适配(固化重放、模式覆盖、STA 分发) +├── plot.py # 无头运行下抑制显示绘图的绘图适配 +├── runtime_env.py # sizer 运行时预检与隔离加载环境 +├── floorplan_mode.py # 隔离候选的 Floorplan 模式(die_util/die_size)覆盖 +├── candidate_clone.py # 候选 workspace 克隆的忽略规则 +├── candidate_worker.py # 隔离 worker 进程中执行候选阶段循环 +├── candidate_resume.py # 失败候选在原 workspace 上的断点续跑 +├── sta_parallel.py # 全角 STA 并行调度(隔离原生进程) +├── sta_benchmark.py # STA 调度基准测试(隔离副本上比较方案) +└── data/ # 候选与观测的数据模型、注册表和落盘产物 + ├── candidate_registry.py # 受控 knob 与后端需求的静态注册表 + ├── candidate_capabilities.py # 稳定的候选能力元数据导出 + ├── candidate_materialization.py # 可重放的配置固化与回执校验 + ├── candidate_input_binding.py # 受控上游输入绑定(阶段间数据边) + ├── candidate_contract.py # 配置/检查点回执的配对一致性检查 + ├── candidate_artifacts.py # canonical JSON、SHA-256、原子写盘工具 + ├── parameter_runtime_observer.py # DREAMPlace 五参数运行时观测 + ├── floorplan_parameter_observer.py # Floorplan 两参数运行时观测 + ├── parameter_application_receipt.py # hash 绑定的参数应用回执生成 + ├── observed_callable.py # 保持原属性的观测包装 + └── foundation/ # workspace 证据提取子包 + ├── extractor.py # 提取流水线(profile: iccd_full_v1) + ├── schema.py # ExtractionResult + ├── parsers/ # LEF/DEF/STA/DRC/布线日志等解析器 + ├── grid/ # GCell 网格规范化 + ├── table_contract.py # 数据表结构契约与写出 + └── writers.py # JSON/JSONL 写出 +``` + +分层关系:`rpc_server` → `server`(方法分发、错误码映射)→ `methods`/`requests` +(schema)→ `workspace_api`(Agent RPC 处理器)→ `engine`/`tools`/`candidate_*` +(执行基础设施)→ `data/*`(注册表、固化与回执落盘)。 + +## RPC 方法面 + +`AgentRuntimeServer` 继承基础 runtime 的全部方法(workspace 生命周期、配置 +读写、`flow.run`/`flow.run_step`、operation 状态与取消、快照等,完整清单见 +`chipcompiler/runtime/methods.py` 与 `docs/rpc-guide.md`),并在能力协商 +(`rpc.hello`)中追加声明以下 Agent 方法: + +| 方法 | 作用 | +| --- | --- | +| `agent.runtime_preflight` | Agent 运行时预检(如 sizer 可执行文件) | +| `workspace.extract_foundation` | 从 workspace 证据提取 foundation 数据表 | +| `candidate.export_capabilities` | 导出候选能力元数据(受控 knob、后端需求) | +| `candidate.bind_input` | 绑定候选的上游输入(阶段间检查点) | +| `candidate.materialize` | 将候选参数补丁固化为可重放配置 | +| `candidate.rerun` | 克隆候选 workspace 并在隔离 worker 中重跑目标阶段 | +| `candidate.resume` | 在原候选 workspace 上断点续跑失败的候选 | + +传输与分帧协议与基础 runtime 一致(`Content-Length` 分帧的 JSON-RPC 2.0), +详见 `docs/rpc-guide.md`。 + +## 候选重跑生命周期 + +一次受控候选评估的典型调用序列: + +1. **预检**:`agent.runtime_preflight` 确认运行时可用。 +2. **能力导出**:`candidate.export_capabilities` 返回目标阶段允许的受控 + knob 集合与后端需求,上层只能在该集合内提参数。 +3. **绑定与固化**:`candidate.bind_input` 固定上游输入边; + `candidate.materialize` 将参数补丁写入候选配置并生成回执,二者与 + 检查点回执通过 candidate contract 校验配对。 +4. **隔离重跑**:`candidate.rerun` 克隆父 workspace(按忽略规则裁剪产物), + 在独立 worker 进程中按目标阶段重跑,事件经观察者流式回传,结果与 + 状态摘要落盘。 +5. **续跑**:失败的候选可用 `candidate.resume` 在原 workspace 上继续, + 保留原候选记录与 Floorplan 模式,不改变历史语义。 + +Floorplan 模式覆盖(`die_util`/`die_size`)只作用于隔离候选:随请求显式 +给出、持久化到候选 workspace 的 `analysis/floorplan_mode.v1.json`,不影响 +源 workspace 与普通 ECC 流程。 + +## 客户端与联合契约 + +本运行时有两个独立实现的客户端,遵守同一契约: + +- **Electron 前端**(GUI 流程执行): + `ecos/gui/apps/desktop-electron/electron/services/eccRpc/` +- **Agent 后端**(受控自动优化): + `ecos/agent/src/ecos_agent/optimization/ecc/rpc_client.py` + +传输、超时、错误恢复与可执行文件解析的共同契约见 ECOS Studio 仓库的 +`ecos/agent/docs/ecc-agent-rpc.md`;修改本层的 RPC 表面或事件语义时,须 +同步该文档与两侧客户端。 + +## 开发与测试 + +```bash +# 启动 stdio RPC 服务(等价于 ecc-agent-rpc 入口) +uv run python -m agent.rpc_server + +# 运行本目录测试(与 chipcompiler 的 test/ 互相独立) +uv run pytest agent/test + +# Lint 与格式 +uv run ruff check agent/ +uv run ruff format agent/ +``` + +测试按被测模块就近放置于 `agent/test/`;foundation 提取器相关的基线数据 +与其表格契约测试同样位于该目录。 From 056311d3ed8896f9afe18455d96adb2d9d97261c Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Wed, 16 Sep 2026 23:47:46 +0800 Subject: [PATCH 90/90] feat(runtime): consolidate agent runtime and add workspace.derive Move candidate operations into the main runtime API and remove the ecc-agent-rpc entrypoint. Add workspace.derive so isolated rerun targets get a fresh engineering identity, empty execution ledger, and an immutable source workspace, with optional scoped flow reset. --- agent/README.cn.md | 50 +-- agent/README.md | 64 ++-- agent/data/candidate_capabilities.py | 10 +- agent/methods.py | 22 +- agent/requests.py | 22 -- agent/rpc_server.py | 18 - agent/server.py | 4 +- .../data/test_candidate_materialization.py | 7 +- agent/test/test_requests.py | 93 +++-- agent/workspace_api.py | 40 +-- chipcompiler/docs/ecc-user-guide.en.md | 2 +- chipcompiler/engine/workspace_derive.py | 220 ++++++++++++ chipcompiler/runtime/methods.py | 6 + chipcompiler/runtime/requests.py | 10 + chipcompiler/runtime/stdio_server.py | 4 +- chipcompiler/runtime/workspace_api.py | 29 ++ docs/rpc-guide.md | 35 +- ecc.spec | 6 - packaging/run_ecc.py | 7 +- pyproject.toml | 1 - test/engine/test_workspace_derive.py | 37 ++ test/packaging/test_cli_entrypoint.py | 6 +- test/packaging/test_run_ecc.py | 32 +- test/runtime/test_methods.py | 1 + test/runtime/test_requests.py | 6 + test/runtime/test_server.py | 3 + test/runtime/test_stdio_server.py | 5 + test/runtime/test_workspace_api.py | 328 ++++++++++++++++++ 28 files changed, 804 insertions(+), 264 deletions(-) delete mode 100644 agent/rpc_server.py create mode 100644 chipcompiler/engine/workspace_derive.py create mode 100644 test/engine/test_workspace_derive.py diff --git a/agent/README.cn.md b/agent/README.cn.md index 52768ebe1..377a2d552 100644 --- a/agent/README.cn.md +++ b/agent/README.cn.md @@ -1,50 +1,53 @@ -# `ecc-agent-rpc` 入口说明 +# Candidate Runtime 入口说明 -`pyproject.toml` 中的如下声明会在安装 ECC 时生成独立可执行文件: +ECC 只发布 `ecc` 可执行文件。Candidate 方法通过内部 server composition +注册到 `ecc rpc serve --stdio`,不再提供独立的 `ecc-agent-rpc` 入口。 ```toml -scripts.ecc-agent-rpc = "agent.rpc_server:main" +scripts.ecc = "chipcompiler.cli.main:main" ``` -它是 ECOS Agent 专用的 ECC JSON-RPC 边车进程入口。桌面端或 Agent -运行时启动该进程,通过标准输入发送请求,并从标准输出读取结果;它不是 -供交互式使用的 `ecc` CLI,也不替代通用的 `ecc rpc serve --stdio`。 +桌面端或离线研究客户端启动该进程,通过标准输入发送请求,并从标准输出 +读取结果。这是通用 ECC JSON-RPC sidecar,也是 Candidate Execution 的唯一 +产品入口。 -## `main()` 做什么 +## `ecc rpc serve` 做什么 -入口实现位于 [`rpc_server.py`](rpc_server.py)。`main()` 保持很小,只完成 -三个启动职责: +`chipcompiler/cli/commands/rpc.py` 调用 +[`stdio_server.main()`](../chipcompiler/runtime/stdio_server.py)。`main()` +保持很小,只完成启动职责: -1. 调用 `multiprocessing.freeze_support()`,使打包后的 Windows 进程可以安全 - 创建子进程; -2. 创建 `AgentRuntimeServer`,在通用 ECC runtime 方法之上注册 Agent 方法; +1. 创建 `AgentRuntimeServer`,在通用 ECC runtime 方法之上注册 Candidate + 方法;普通 `workspace.*` 与 `flow.*` 仍使用通用 Workspace Runtime API; +2. 启动时准备可选的打包 Sizer 运行时路径; 3. 将二进制 `stdin`/`stdout` 和该 server 交给 `chipcompiler.runtime.stdio_server.run_stdio_server()`。 这样,入口不复制 transport、JSON-RPC 分发或业务执行逻辑。传输层统一处理 -请求帧、响应串行写出、`runtime.event` 通知和 `rpc.shutdown`;Agent 行为由 -`AgentRuntimeServer`、`FlowAgentRuntimeApi` 及其受控 workspace API 实现。 +请求帧、响应串行写出、`runtime.event` 通知和 `rpc.shutdown`;Candidate +行为由 `AgentRuntimeServer`、`FlowAgentRuntimeApi` 及其隔离执行实现。 ## 协议与能力 该进程使用带 `Content-Length` 头的 JSON-RPC 2.0 stdio 协议。标准输出只可写入 协议帧,诊断和工具输出应写入标准错误或 workspace 日志,避免破坏客户端解码。 -`rpc.hello` 返回的 capabilities 包含通用 runtime 方法,以及 Agent 专用方法: +`rpc.hello` 返回的 capabilities 包含通用 runtime 方法,以及 Candidate 方法: -- `agent.runtime_preflight`:检查 Agent 候选执行所需运行时; - `workspace.extract_foundation`:提取已完成 workspace 的 foundation 数据; -- `candidate.export_capabilities`、`candidate.bind_input`、 - `candidate.materialize`:查询或准备受控候选; +- `candidate.capabilities`:查询当前 workspace 的受控候选能力; - `candidate.rerun`、`candidate.resume`:启动或恢复受控候选执行。 +`agent.runtime_preflight`、`candidate.bind_input` 和 `candidate.materialize` +不是公开 RPC。预检、输入绑定和配置固化只作为 `candidate.rerun` 的内部步骤。 + 方法名、请求模型和处理函数的权威定义在 [`methods.py`](methods.py)。入口收到 请求后会将 camelCase 字段归一化为请求模型字段;无效字段或重复字段返回 `invalid_request`,不会转化为任意命令执行。 ## 运行边界 -`ecc-agent-rpc` 仅暴露已注册的 typed RPC 方法。它不接收自然语言计划、不选择 +`ecc rpc serve` 仅暴露已注册的 typed RPC 方法。它不接收自然语言计划、不选择 优化参数,也不执行调用方提供的任意 shell 命令。候选操作仍由 Agent runtime 的 参数校验、workspace 边界和执行回执约束。 @@ -55,9 +58,8 @@ workspace 产物作为执行证据。 ## 维护约定 -- 新增 Agent RPC 方法时,同时更新 `methods.py` 的 `AGENT_RUNTIME_METHODS`、 - 请求模型、workspace API 和对应测试;`rpc_server.py` 通常无需修改。 -- 修改 stdio framing 或通用 runtime 行为时,应修改 `chipcompiler/runtime/` 并 - 评估 `ecc rpc serve --stdio` 与 `ecc-agent-rpc` 两个入口。 -- 直接调试可运行 `python -m agent.rpc_server`,但输入必须是合法的 +- 新增 Candidate RPC 方法时,同时更新 `methods.py` 的 `AGENT_RUNTIME_METHODS`、 + 请求模型、workspace API 和对应测试;`stdio_server.main()` 通常无需修改。 +- 修改 stdio framing 或通用 runtime 行为时,应修改 `chipcompiler/runtime/`。 +- 直接调试可运行 `uv run ecc rpc serve --stdio`,但输入必须是合法的 `Content-Length` JSON-RPC 帧;普通命令行参数不会被解析为 RPC 请求。 diff --git a/agent/README.md b/agent/README.md index d6bccebfc..e2012325a 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,9 +1,9 @@ # ECC Agent Runtime(`agent/`) `agent/` 是 ECC 的 **Flow Agent 运行时**:在 `chipcompiler` 标准 runtime 之上构建的 -受控、可审计的流程代理执行层。它以独立 sidecar 进程(可执行入口 -`ecc-agent-rpc`)运行,通过 JSON-RPC 2.0 over stdio 对外提供服务,供 -ECOS Studio 的 GUI 前端与 `ecos_agent` 受控优化后端驱动。 +受控、可审计的流程代理执行层。它通过内部 server composition 注册到唯一的 +`ecc rpc serve` JSON-RPC 入口,供 ECOS Studio 的 GUI 前端与 `ecos_agent` +受控优化后端驱动。产品中只发布 `ecc` 可执行文件,不再提供独立的 Agent sidecar。 它只做确定性的执行与证据记录:**决策不在这里**。`agent/` 不包含任何 LLM 代码,不解析自由文本指令,也不直接执行 shell 命令;流程决策由上层 @@ -37,16 +37,18 @@ ECOS Studio 的 GUI 前端与 `ecos_agent` 受控优化后端驱动。 中执行,全角 STA 同样使用隔离原生进程。 5. **不反向依赖**:`agent/` 依赖 `chipcompiler` 的公开 runtime 接口并继承 扩展,不修改其行为;除观测回执的结构化输入外,不依赖 `ecos_agent`。 +6. **不改变普通 Flow**:普通 `workspace.*` 与 `flow.*` 继续使用通用 Workspace + Runtime API 和 Engine Flow;只有 Candidate Execution 在自己的 operation + 边界内构造 Candidate Flow。 ## 代码结构 ```text agent/ -├── rpc_server.py # 进程入口:stdin/stdout 上启动 stdio JSON-RPC 服务 -├── server.py # AgentRuntimeServer:扩展基础 RuntimeServer 与能力协商 +├── server.py # AgentRuntimeServer:在通用 RuntimeServer 上组合 Candidate 方法 ├── methods.py # Agent RPC 方法表(RuntimeMethodSpec 声明) ├── requests.py # RPC 请求模型(frozen dataclass + 校验) -├── workspace_api.py # AgentWorkspaceRuntimeApi / FlowAgentRuntimeApi:RPC 处理层 +├── workspace_api.py # FlowAgentRuntimeApi:Candidate RPC 处理层 ├── engine.py # AgentEngineFlow:流程引擎覆盖(观察者、渲染门控、监控) ├── tools.py # Agent 侧步骤执行适配(固化重放、模式覆盖、STA 分发) ├── plot.py # 无头运行下抑制显示绘图的绘图适配 @@ -59,7 +61,7 @@ agent/ ├── sta_benchmark.py # STA 调度基准测试(隔离副本上比较方案) └── data/ # 候选与观测的数据模型、注册表和落盘产物 ├── candidate_registry.py # 受控 knob 与后端需求的静态注册表 - ├── candidate_capabilities.py # 稳定的候选能力元数据导出 + ├── candidate_capabilities.py # 当前 workspace 的候选能力查询 ├── candidate_materialization.py # 可重放的配置固化与回执校验 ├── candidate_input_binding.py # 受控上游输入绑定(阶段间数据边) ├── candidate_contract.py # 配置/检查点回执的配对一致性检查 @@ -77,45 +79,42 @@ agent/ └── writers.py # JSON/JSONL 写出 ``` -分层关系:`rpc_server` → `server`(方法分发、错误码映射)→ `methods`/`requests` -(schema)→ `workspace_api`(Agent RPC 处理器)→ `engine`/`tools`/`candidate_*` -(执行基础设施)→ `data/*`(注册表、固化与回执落盘)。 +分层关系:`ecc rpc serve` → `stdio_server.main()` 组合 `AgentRuntimeServer` +(方法分发、错误码映射)→ `methods`/`requests`(schema)→ `workspace_api` +(Agent RPC 处理器)→ `engine`/`tools`/`candidate_*`(执行基础设施)→ +`data/*`(注册表、固化与回执落盘)。 ## RPC 方法面 `AgentRuntimeServer` 继承基础 runtime 的全部方法(workspace 生命周期、配置 读写、`flow.run`/`flow.run_step`、operation 状态与取消、快照等,完整清单见 `chipcompiler/runtime/methods.py` 与 `docs/rpc-guide.md`),并在能力协商 -(`rpc.hello`)中追加声明以下 Agent 方法: +(`rpc.hello`)中追加声明以下方法: | 方法 | 作用 | | --- | --- | -| `agent.runtime_preflight` | Agent 运行时预检(如 sizer 可执行文件) | | `workspace.extract_foundation` | 从 workspace 证据提取 foundation 数据表 | -| `candidate.export_capabilities` | 导出候选能力元数据(受控 knob、后端需求) | -| `candidate.bind_input` | 绑定候选的上游输入(阶段间检查点) | -| `candidate.materialize` | 将候选参数补丁固化为可重放配置 | -| `candidate.rerun` | 克隆候选 workspace 并在隔离 worker 中重跑目标阶段 | +| `candidate.capabilities` | 查询当前 workspace 的候选能力(受控 knob、后端需求) | +| `candidate.rerun` | 原子克隆候选 workspace 并在隔离 worker 中重跑目标阶段 | | `candidate.resume` | 在原候选 workspace 上断点续跑失败的候选 | 传输与分帧协议与基础 runtime 一致(`Content-Length` 分帧的 JSON-RPC 2.0), -详见 `docs/rpc-guide.md`。 +详见 `docs/rpc-guide.md`。输入绑定、配置 materialization 和运行时预检保留为 +`candidate.rerun` 的内部步骤,不作为独立 RPC 方法。 ## 候选重跑生命周期 -一次受控候选评估的典型调用序列: +一次受控候选评估的公开调用序列: -1. **预检**:`agent.runtime_preflight` 确认运行时可用。 -2. **能力导出**:`candidate.export_capabilities` 返回目标阶段允许的受控 - knob 集合与后端需求,上层只能在该集合内提参数。 -3. **绑定与固化**:`candidate.bind_input` 固定上游输入边; - `candidate.materialize` 将参数补丁写入候选配置并生成回执,二者与 - 检查点回执通过 candidate contract 校验配对。 -4. **隔离重跑**:`candidate.rerun` 克隆父 workspace(按忽略规则裁剪产物), - 在独立 worker 进程中按目标阶段重跑,事件经观察者流式回传,结果与 - 状态摘要落盘。 -5. **续跑**:失败的候选可用 `candidate.resume` 在原 workspace 上继续, - 保留原候选记录与 Floorplan 模式,不改变历史语义。 +1. **能力查询**:`candidate.capabilities` 返回目标阶段允许的受控 knob 集合 + 与后端需求,上层只能在该集合内提参数。这是查询,不写第二份参数目录。 +2. **原子重跑**:`candidate.rerun` 在 ECC 内部完成预检、源 workspace 快照、 + 克隆、输入绑定、参数固化、Floorplan 模式覆盖,并在独立 worker 进程中按 + 目标阶段重跑。事件经观察者流式回传,结果与状态摘要落盘。失败的准备步骤 + 不会留下可执行的半成品 Candidate。 +3. **续跑**:失败的候选可用 `candidate.resume` 在原 workspace 上继续, + 保留原候选记录与 Floorplan 模式,不改变历史语义。取消与恢复复用通用 + Operation 生命周期。 Floorplan 模式覆盖(`die_util`/`die_size`)只作用于隔离候选:随请求显式 给出、持久化到候选 workspace 的 `analysis/floorplan_mode.v1.json`,不影响 @@ -132,13 +131,14 @@ Floorplan 模式覆盖(`die_util`/`die_size`)只作用于隔离候选:随 传输、超时、错误恢复与可执行文件解析的共同契约见 ECOS Studio 仓库的 `ecos/agent/docs/ecc-agent-rpc.md`;修改本层的 RPC 表面或事件语义时,须 -同步该文档与两侧客户端。 +同步该文档与两侧客户端。生产路径应通过 Electron Product Command 调用 +`ecc rpc serve`,而不是再启动第二个 Agent executable。 ## 开发与测试 ```bash -# 启动 stdio RPC 服务(等价于 ecc-agent-rpc 入口) -uv run python -m agent.rpc_server +# 启动 stdio RPC 服务(Candidate 方法已组合进该入口) +uv run ecc rpc serve --stdio # 运行本目录测试(与 chipcompiler 的 test/ 互相独立) uv run pytest agent/test diff --git a/agent/data/candidate_capabilities.py b/agent/data/candidate_capabilities.py index 48cb9cb04..97a2036b2 100644 --- a/agent/data/candidate_capabilities.py +++ b/agent/data/candidate_capabilities.py @@ -3,7 +3,6 @@ from dataclasses import asdict from typing import Any -from .candidate_artifacts import workspace_analysis_path, write_json_atomic from .candidate_registry import ( CANDIDATE_TARGET_BACKENDS, candidate_capability_registry, @@ -13,7 +12,6 @@ CAPABILITIES_SCHEMA = "ecc.workspace.candidate_capabilities.v1" CAPABILITIES_SCHEMA_VERSION = 1 -CAPABILITIES_FILENAME = "candidate_capabilities.v1.json" EXCLUDED_CONFIGURATION_GROUPS = { "Floorplan": [ @@ -30,9 +28,9 @@ def export_candidate_capabilities(workspace: Any) -> dict[str, Any]: - """Write and return the deterministic candidate capability contract.""" + """Return the current workspace's candidate capability contract.""" grouped = _group_knobs_by_target() - payload = { + return { "schema": CAPABILITIES_SCHEMA, "schema_version": CAPABILITIES_SCHEMA_VERSION, "registry_sha256": candidate_registry_digest(), @@ -41,8 +39,6 @@ def export_candidate_capabilities(workspace: Any) -> dict[str, Any]: for target_step, available_knobs, unavailable_knobs in grouped ], } - write_json_atomic(_capabilities_path(workspace), payload) - return payload def _group_knobs_by_target() -> list[tuple[str, list[Any], list[Any]]]: @@ -106,5 +102,3 @@ def _backend_unavailable_knobs(knobs: list[dict[str, Any]], reason: str) -> list return unavailable -def _capabilities_path(workspace: Any): - return workspace_analysis_path(workspace.directory, CAPABILITIES_FILENAME) diff --git a/agent/methods.py b/agent/methods.py index 66da427b6..e21798dae 100644 --- a/agent/methods.py +++ b/agent/methods.py @@ -4,39 +4,21 @@ from chipcompiler.runtime.requests import WorkspaceIdRequest from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, CandidateRerunRequest, CandidateResumeRequest, - RuntimePreflightRequest, WorkspaceExtractFoundationRequest, ) AGENT_RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( - RuntimeMethodSpec( - method_name="agent.runtime_preflight", - request_model=RuntimePreflightRequest, - handler_name="runtime_preflight", - ), RuntimeMethodSpec( method_name="workspace.extract_foundation", request_model=WorkspaceExtractFoundationRequest, handler_name="extract_foundation", ), RuntimeMethodSpec( - method_name="candidate.export_capabilities", + method_name="candidate.capabilities", request_model=WorkspaceIdRequest, - handler_name="export_candidate_capabilities", - ), - RuntimeMethodSpec( - method_name="candidate.bind_input", - request_model=CandidateBindInputRequest, - handler_name="bind_candidate_input", - ), - RuntimeMethodSpec( - method_name="candidate.materialize", - request_model=CandidateMaterializeRequest, - handler_name="materialize_candidate", + handler_name="candidate_capabilities", ), RuntimeMethodSpec( method_name="candidate.rerun", diff --git a/agent/requests.py b/agent/requests.py index 4780011d9..fef46f361 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -4,32 +4,11 @@ from chipcompiler.runtime.requests import RequestValidationError, parse_request_model -@dataclass(frozen=True) -class RuntimePreflightRequest: - pass - - @dataclass(frozen=True) class WorkspaceExtractFoundationRequest: workspace_id: str -@dataclass(frozen=True) -class CandidateBindInputRequest: - workspace_id: str - target_step: str - source_step: str - candidate_id: str - - -@dataclass(frozen=True) -class CandidateMaterializeRequest: - workspace_id: str - target_step: str - candidate_id: str - patch: list[dict[str, Any]] - - @dataclass(frozen=True) class CandidateRerunRequest: workspace_id: str @@ -60,7 +39,6 @@ class CandidateResumeRequest: "workspaceId": "workspace_id", "targetStep": "target_step", "endStep": "end_step", - "sourceStep": "source_step", "candidateId": "candidate_id", "executionScope": "execution_scope", "idempotencyKey": "idempotency_key", diff --git a/agent/rpc_server.py b/agent/rpc_server.py deleted file mode 100644 index 7447baa0c..000000000 --- a/agent/rpc_server.py +++ /dev/null @@ -1,18 +0,0 @@ -import multiprocessing -import sys - -from agent.server import AgentRuntimeServer -from chipcompiler.runtime.stdio_server import run_stdio_server - - -def main() -> int: - multiprocessing.freeze_support() - return run_stdio_server( - sys.stdin.buffer, - sys.stdout.buffer, - server=AgentRuntimeServer(), - ) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/agent/server.py b/agent/server.py index c2a65b1f6..75ec01c59 100644 --- a/agent/server.py +++ b/agent/server.py @@ -7,7 +7,7 @@ from .methods import AGENT_RUNTIME_METHODS, agent_method_names from .requests import parse_agent_request_model from .runtime_env import prepare_agent_runtime_environment -from .workspace_api import AgentWorkspaceRuntimeApi, FlowAgentRuntimeApi +from .workspace_api import FlowAgentRuntimeApi class AgentRuntimeServer(RuntimeServer): @@ -19,7 +19,7 @@ def __init__( ): prepare_agent_runtime_environment() super().__init__( - api=api or AgentWorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled), + api=api or WorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled), persistent_db_enabled=persistent_db_enabled, ) self.agent_api = FlowAgentRuntimeApi(self.api) diff --git a/agent/test/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index 2cd94dc73..9e826a06f 100644 --- a/agent/test/data/test_candidate_materialization.py +++ b/agent/test/data/test_candidate_materialization.py @@ -543,12 +543,13 @@ def test_materialize_rejects_invalid_candidate_id(tmp_path, candidate_id): ) -def test_export_capabilities_writes_stable_schema_and_backend_truth(tmp_path): +def test_export_capabilities_returns_stable_schema_and_backend_truth_without_writing_catalog( + tmp_path, +): workspace = _workspace(tmp_path) capabilities = export_candidate_capabilities(workspace) - persisted = _read_json(tmp_path / "analysis" / "candidate_capabilities.v1.json") cts = next(item for item in capabilities["targets"] if item["target_step"] == "CTS") legalization = next( item for item in capabilities["targets"] if item["target_step"] == "legalization" @@ -556,7 +557,7 @@ def test_export_capabilities_writes_stable_schema_and_backend_truth(tmp_path): filler = next(item for item in capabilities["targets"] if item["target_step"] == "filler") floorplan = next(item for item in capabilities["targets"] if item["target_step"] == "Floorplan") - assert capabilities == persisted + assert not (tmp_path / "analysis" / "candidate_capabilities.v1.json").exists() assert capabilities["schema"] == "ecc.workspace.candidate_capabilities.v1" assert capabilities["schema_version"] == 1 assert capabilities["registry_sha256"].startswith("sha256:") diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py index 0f7df248e..5bd5a43cb 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -9,46 +9,40 @@ from agent.requests import ( CandidateRerunRequest, CandidateResumeRequest, - RuntimePreflightRequest, parse_agent_request_model, ) from agent.server import AgentRuntimeServer from chipcompiler.runtime.requests import RequestValidationError from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame +from chipcompiler.runtime.workspace_api import WorkspaceRuntimeApi CONTEXT_SHA256 = "sha256:" + "a" * 64 PARAMETER_CARD_SHA256 = "sha256:" + "b" * 64 -def test_agent_methods_keep_the_original_rpc_names(): - assert agent_method_names() == ( - "agent.runtime_preflight", - "workspace.extract_foundation", - "candidate.export_capabilities", - "candidate.bind_input", - "candidate.materialize", - "candidate.rerun", - "candidate.resume", - ) - +PUBLIC_CANDIDATE_METHODS = ( + "workspace.extract_foundation", + "candidate.capabilities", + "candidate.rerun", + "candidate.resume", +) +REMOVED_PUBLIC_METHODS = ( + "agent.runtime_preflight", + "candidate.export_capabilities", + "candidate.bind_input", + "candidate.materialize", +) -def test_agent_runtime_server_registers_isolated_methods(): - server = AgentRuntimeServer() - assert set(agent_method_names()).issubset(server.capabilities) +def test_agent_methods_keep_the_public_candidate_rpc_names(): + assert agent_method_names() == PUBLIC_CANDIDATE_METHODS -def test_runtime_preflight_is_read_only_and_checks_agent_tools(monkeypatch): - calls = [] - monkeypatch.setattr( - "agent.workspace_api.preflight_sizer_runtime", lambda: calls.append("preflight") - ) +def test_agent_runtime_server_registers_isolated_methods(): server = AgentRuntimeServer() - result = server.agent_api.runtime_preflight(RuntimePreflightRequest()) - - assert result == {"sizer": True, "dreamplace": True} - assert calls == ["preflight"] + assert set(agent_method_names()).issubset(server.capabilities) + assert not set(REMOVED_PUBLIC_METHODS) & set(server.capabilities) def test_agent_runtime_server_prepares_agent_environment(monkeypatch): @@ -63,19 +57,23 @@ def test_agent_runtime_server_prepares_agent_environment(monkeypatch): assert calls == [True] -def test_agent_runtime_server_builds_full_flows_with_agent_engine(monkeypatch): - flow = SimpleNamespace(engine_db=None) +def test_agent_runtime_server_uses_generic_workspace_api_for_ordinary_flow(): + server = AgentRuntimeServer() + + assert type(server.api) is WorkspaceRuntimeApi + + +def test_candidate_execution_builds_agent_engine_flow(monkeypatch): + flow = SimpleNamespace() monkeypatch.setattr( "agent.workspace_api.build_agent_flow_for_workspace", - lambda _workspace: flow, + lambda _workspace, **_kwargs: flow, ) server = AgentRuntimeServer() - session = SimpleNamespace(workspace=SimpleNamespace(), db_handle=object()) - result = server.api._build_flow_for_session(session, attach_session_db=True) + result = server.agent_api._build_flow(SimpleNamespace()) assert result is flow - assert result.engine_db is session.db_handle def test_agent_request_normalizes_camel_case_fields(): @@ -236,28 +234,25 @@ def test_candidate_rerun_rejects_a_multi_knob_patch_as_an_invalid_request(): } -def test_agent_rpc_uses_dedicated_entrypoint(): +def test_ecc_rpc_serve_advertises_candidate_methods(): def request(method: str, request_id: int, params: dict | None = None) -> bytes: payload = {"jsonrpc": "2.0", "method": method, "id": request_id} if params is not None: payload["params"] = params return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) - def capabilities() -> list[str]: - command = [ - sys.executable, - "-m", - "agent.rpc_server", - ] - completed = subprocess.run( - command, - input=request("rpc.hello", 1, {"version": 1}) + request("rpc.shutdown", 2), - capture_output=True, - check=False, - ) - decoder = ContentLengthDecoder() - responses = [json.loads(message) for message in decoder.feed(completed.stdout)] - assert completed.returncode == 0, completed.stderr.decode("utf-8", errors="replace") - return responses[0]["result"]["capabilities"] - - assert "candidate.rerun" in capabilities() + completed = subprocess.run( + [sys.executable, "-m", "chipcompiler.cli.main", "rpc", "serve", "--stdio"], + input=request("rpc.hello", 1, {"version": 1}) + request("rpc.shutdown", 2), + capture_output=True, + check=False, + ) + decoder = ContentLengthDecoder() + responses = [json.loads(message) for message in decoder.feed(completed.stdout)] + assert completed.returncode == 0, completed.stderr.decode("utf-8", errors="replace") + capabilities = responses[0]["result"]["capabilities"] + + for method_name in PUBLIC_CANDIDATE_METHODS: + assert method_name in capabilities + for method_name in REMOVED_PUBLIC_METHODS: + assert method_name not in capabilities diff --git a/agent/workspace_api.py b/agent/workspace_api.py index b1293cb87..c73bbf11c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -43,8 +43,6 @@ validate_floorplan_mode_result, ) from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, CandidateRerunRequest, CandidateResumeRequest, WorkspaceExtractFoundationRequest, @@ -92,24 +90,12 @@ def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = return flow -class AgentWorkspaceRuntimeApi(WorkspaceRuntimeApi): - def _build_flow_for_session(self, session, *, attach_session_db: bool): - flow = build_agent_flow_for_workspace(session.workspace) - if attach_session_db: - flow.engine_db = session.db_handle - return flow - - class FlowAgentRuntimeApi: """Optional Flow Agent RPC handlers over one ECC workspace runtime.""" def __init__(self, ecc_api: WorkspaceRuntimeApi): self.ecc_api = ecc_api - def runtime_preflight(self, _request) -> dict[str, bool]: - preflight_sizer_runtime() - return {"sizer": True, "dreamplace": True} - def extract_foundation(self, request: WorkspaceExtractFoundationRequest) -> dict: def extract(session): workspace_dir = Path(session.workspace.directory).resolve() @@ -122,36 +108,12 @@ def extract(session): return self._with_workspace_lock(request.workspace_id, extract) - def export_candidate_capabilities(self, request: WorkspaceIdRequest) -> dict: + def candidate_capabilities(self, request: WorkspaceIdRequest) -> dict: return self._with_workspace_lock( request.workspace_id, lambda session: export_candidate_capabilities(session.workspace), ) - def bind_candidate_input(self, request: CandidateBindInputRequest) -> dict: - def bind(session): - flow = build_agent_flow_for_workspace(session.workspace) - return bind_candidate_input( - session.workspace, - flow, - request.target_step, - request.source_step, - request.candidate_id, - ) - - return self._with_workspace_lock(request.workspace_id, bind) - - def materialize_candidate(self, request: CandidateMaterializeRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: materialize_candidate_config( - session.workspace, - request.target_step, - request.patch, - request.candidate_id, - ), - ) - def candidate_rerun(self, request: CandidateRerunRequest) -> dict: _validate_candidate_rerun_request(request) session = self.ecc_api._get_session(request.workspace_id) diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md index db19bae19..3721e3625 100644 --- a/chipcompiler/docs/ecc-user-guide.en.md +++ b/chipcompiler/docs/ecc-user-guide.en.md @@ -1078,7 +1078,7 @@ A JSON-RPC 2.0 service for front ends such as the GUI, framed with `Content-Leng ```console → {"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello-1"} -← {"jsonrpc":"2.0","result":{"version":1,"eccVersion":"0.1.0-alpha.11","capabilities":["rpc.hello","rpc.ping","rpc.shutdown","runtime.v2","operation.events","workspace.create","workspace.open","workspace.close","workspace.home","workspace.info","workspace.refresh_config","workspace.sync_config","workspace.reset_flow","workspace.export_signoff","workspace.inspect_signoff","flow.run","flow.run_step","operation.start_flow","operation.start_step","operation.status","operation.cancel","operation.ack_step_rendered","workspace.snapshot","workspace.recover_interrupted"]},"id":"hello-1"} +← {"jsonrpc":"2.0","result":{"version":1,"eccVersion":"0.1.0-alpha.11","capabilities":["rpc.hello","rpc.ping","rpc.shutdown","runtime.v2","operation.events","workspace.create","workspace.open","workspace.derive","workspace.close","workspace.home","workspace.info","workspace.refresh_config","workspace.sync_config","workspace.reset_flow","workspace.export_signoff","workspace.inspect_signoff","flow.run","flow.run_step","operation.start_flow","operation.start_step","operation.status","operation.cancel","operation.ack_step_rendered","workspace.snapshot","workspace.recover_interrupted"]},"id":"hello-1"} → {"jsonrpc":"2.0","method":"rpc.ping","params":{},"id":"ping-1"} ← {"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"} diff --git a/chipcompiler/engine/workspace_derive.py b/chipcompiler/engine/workspace_derive.py new file mode 100644 index 000000000..30aebe000 --- /dev/null +++ b/chipcompiler/engine/workspace_derive.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +"""Derive a fresh-identity Workspace copy from an existing Workspace.""" + +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from chipcompiler.engine.snapshot import ( + SNAPSHOT_FILENAME, + STALE_SNAPSHOT_FILENAME, + create_engineering_snapshot, +) +from chipcompiler.engine.workspace_lifecycle import ( + WorkspaceLifecycleError, + _replace_string_prefix, + _workspace_command_fingerprint, + _write_workspace_command, +) +from chipcompiler.utility.path import path_is_within + +_RUNTIME_COMMANDS_FILENAME = "runtime-commands.json" +_WORKSPACE_COMMANDS_FILENAME = "workspace-commands.json" + + +def derive_workspace( + source_directory: str | Path, + target_directory: str | Path, + *, + reset_from_step: str = "", + command_id: str = "", + cause: str = "workspace.derived", +) -> Any: + """Copy ``source_directory`` to ``target_directory`` under a new identity. + + The source stays read-only and byte-identical. The target receives a new + Engineering Snapshot (fresh workspaceId, revision 1, ``cause``), an empty + runtime command ledger, and no inherited workspace command records. With an + empty ``reset_from_step`` the whole flow is prepared for a rerun; otherwise + only the named step and its flow suffix are reset while earlier steps keep + their Success state and artifacts. + + Returns the loaded derived workspace. + """ + from chipcompiler.data import load_workspace + from chipcompiler.engine.reconcile import _workspace_lock + + source = Path(source_directory).expanduser().resolve() + target = Path(target_directory).expanduser().resolve() + if target.exists(): + raise WorkspaceLifecycleError("workspace_exists", f"Workspace already exists: {target}") + if path_is_within(target, source): + raise WorkspaceLifecycleError( + "workspace_invalid", f"Target directory is inside the source Workspace: {target}" + ) + if not (source / "home" / SNAPSHOT_FILENAME).is_file(): + raise WorkspaceLifecycleError( + "workspace_invalid", f"Workspace has no Engineering Snapshot: {source}" + ) + + with _workspace_lock(source), _workspace_lock(target): + if target.exists(): + raise WorkspaceLifecycleError("workspace_exists", f"Workspace already exists: {target}") + staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.staging-", dir=target.parent)) + staging.rmdir() + try: + shutil.copytree(source, staging) + home = staging / "home" + for name in ( + STALE_SNAPSHOT_FILENAME, + _RUNTIME_COMMANDS_FILENAME, + _WORKSPACE_COMMANDS_FILENAME, + ): + (home / name).unlink(missing_ok=True) + + workspace = load_workspace(staging) + if workspace is None: + raise WorkspaceLifecycleError( + "workspace_invalid", f"Workspace cannot be opened: {source}" + ) + + from chipcompiler.runtime.workspace_api import ( + WorkspaceRuntimeApi, + build_flow_for_workspace, + ) + + engine_flow = build_flow_for_workspace(workspace) + if reset_from_step: + reset_steps = _reset_step_suffix(engine_flow, reset_from_step) + WorkspaceRuntimeApi._prepare_steps_for_rerun(workspace, engine_flow, reset_steps) + home_data = getattr(getattr(workspace, "home", None), "data", None) + if isinstance(home_data, dict): + home_data["checklist"] = str(home / "checklist.json") + _prune_derived_home(workspace, reset_steps) + _prune_derived_checklist(workspace, reset_steps) + else: + import chipcompiler.data as data_api + + data_api.prepare_workspace_for_rerun( + workspace, engine_flow, preserve_user_inputs=True + ) + + _rewrite_staged_paths( + staging, + ((str(source), str(target)), (str(staging), str(target))), + ) + snapshot = create_engineering_snapshot(workspace, cause=cause) + if command_id: + _write_workspace_command( + staging, + command_id, + _workspace_command_fingerprint( + "derive", + { + "directory": str(source), + "targetDirectory": str(target), + "resetFromStep": reset_from_step, + }, + None, + ), + snapshot["workspaceId"], + snapshot["workspaceRevision"], + ) + staging.rename(target) + try: + derived = load_workspace(target) + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise + if derived is None: + shutil.rmtree(target, ignore_errors=True) + raise WorkspaceLifecycleError( + "workspace_invalid", f"Derived Workspace cannot be opened: {target}" + ) + return derived + finally: + shutil.rmtree(staging, ignore_errors=True) + + +def _reset_step_suffix(engine_flow, reset_from_step: str) -> list: + workspace_steps = list(getattr(engine_flow, "workspace_steps", [])) + index = next( + ( + position + for position, step in enumerate(workspace_steps) + if str(getattr(step, "name", "")).casefold() == reset_from_step.casefold() + ), + -1, + ) + if index < 0: + raise WorkspaceLifecycleError( + "flow_step_not_found", f"Flow Step not found: {reset_from_step}" + ) + return workspace_steps[index:] + + +def _prune_derived_home(workspace, reset_steps) -> None: + home = getattr(workspace, "home", None) + data = getattr(home, "data", None) + if home is None or not isinstance(data, dict): + return + wiped = {Path(str(getattr(step, "directory", ""))).name for step in reset_steps} + wiped.discard("") + if isinstance(data.get("layout"), str) and _path_in_reset_scope(data["layout"], wiped): + data["layout"] = "" + metrics = data.get("metrics") + if isinstance(metrics, dict): + data["metrics"] = { + key: value + for key, value in metrics.items() + if not (isinstance(value, str) and _path_in_reset_scope(value, wiped)) + } + save = getattr(home, "save", None) + if callable(save): + save() + + +def _path_in_reset_scope(value: str, wiped: set[str]) -> bool: + segments = value.strip().replace("\\", "/").split("/") + return any(segment in wiped for segment in segments if segment) + + +def _prune_derived_checklist(workspace, reset_steps) -> None: + from chipcompiler.data import Checklist + + home = getattr(workspace, "home", None) + data = getattr(home, "data", None) + checklist_text = data.get("checklist", "") if isinstance(data, dict) else "" + path = ( + Path(checklist_text) + if checklist_text + else Path(str(getattr(workspace, "directory", ""))) / "home" / "checklist.json" + ) + if not path.is_file(): + return + wiped = {str(getattr(step, "name", "")) for step in reset_steps} + wiped.discard("") + checklist = Checklist(path) + kept = [ + item + for item in checklist.data.get("checklist", []) + if isinstance(item, dict) and str(item.get("step", "")) not in wiped + ] + checklist.replace(kept) + + +def _rewrite_staged_paths(staging: Path, replacements: tuple[tuple[str, str], ...]) -> None: + from chipcompiler.utility import json_write + + for path in staging.rglob("*.json"): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + rewritten = value + for source, target in replacements: + rewritten = _replace_string_prefix(rewritten, source, target) + if rewritten != value and not json_write(path, rewritten): + raise OSError(f"Failed to rewrite staged Workspace path: {path}") diff --git a/chipcompiler/runtime/methods.py b/chipcompiler/runtime/methods.py index 340aa8f1d..2f590f564 100644 --- a/chipcompiler/runtime/methods.py +++ b/chipcompiler/runtime/methods.py @@ -24,6 +24,7 @@ WorkspaceCloseRequest, WorkspaceConfigurationUpdateRequest, WorkspaceCreateRequest, + WorkspaceDeriveRequest, WorkspaceExportSignoffRequest, WorkspaceIdRequest, WorkspaceInfoRequest, @@ -84,6 +85,11 @@ class RuntimeMethodSpec(Generic[RequestT]): request_model=WorkspaceOpenRequest, handler_name="open_workspace", ), + RuntimeMethodSpec( + method_name="workspace.derive", + request_model=WorkspaceDeriveRequest, + handler_name="derive_workspace", + ), RuntimeMethodSpec( method_name="workspace.binding_requirement", request_model=WorkspaceSpecOpenRequest, diff --git a/chipcompiler/runtime/requests.py b/chipcompiler/runtime/requests.py index b648684cc..6805774c6 100644 --- a/chipcompiler/runtime/requests.py +++ b/chipcompiler/runtime/requests.py @@ -29,6 +29,15 @@ class WorkspaceOpenRequest: workspace_bindings: dict[str, Any] | None = None +@dataclass(frozen=True) +class WorkspaceDeriveRequest: + directory: str + target_directory: str + reset_from_step: str = "" + command_id: str = "" + cause: str = "workspace.derived" + + @dataclass(frozen=True) class EmptyRequest: pass @@ -308,6 +317,7 @@ def __init__(self, reason: str): "projectId": "project_id", "projectRoot": "project_root", "stepId": "step_id", + "resetFromStep": "reset_from_step", } diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index e46e0b6b8..f5b5ff84a 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -124,8 +124,10 @@ def _read_chunk(input_stream: BinaryIO) -> bytes: def main(*, persistent_db_enabled: bool = False) -> int: + from agent.server import AgentRuntimeServer + return run_stdio_server( sys.stdin.buffer, sys.stdout.buffer, - persistent_db_enabled=persistent_db_enabled, + server=AgentRuntimeServer(persistent_db_enabled=persistent_db_enabled), ) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index 930a5e610..343141c83 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -40,6 +40,7 @@ OperationStartFlowRequest, OperationStartStepRequest, WorkspaceCreateRequest, + WorkspaceDeriveRequest, WorkspaceExportSignoffRequest, WorkspaceIdRequest, WorkspaceInfoRequest, @@ -217,6 +218,34 @@ def _open_legacy_workspace(self, request: WorkspaceOpenRequest) -> dict: ) return _workspace_session_result(session) + def derive_workspace(self, request: WorkspaceDeriveRequest) -> dict: + from chipcompiler.engine.snapshot import read_engineering_snapshot + from chipcompiler.engine.workspace_derive import derive_workspace as derive_workspace_copy + from chipcompiler.engine.workspace_lifecycle import WorkspaceLifecycleError + + try: + workspace = derive_workspace_copy( + request.directory, + request.target_directory, + reset_from_step=request.reset_from_step, + command_id=request.command_id, + cause=request.cause, + ) + except WorkspaceLifecycleError as exc: + raise RuntimeApiError(exc.code, str(exc), exc.details) from exc + snapshot = read_engineering_snapshot(workspace) + session = self.sessions.create_session( + workspace.directory, + workspace=workspace, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"], + ) + self.operations.load_workspace_ledger( + session.workspace_id, + session.directory / "home" / "runtime-commands.json", + ) + return _workspace_session_result(session) + def recover_interrupted(self, request: WorkspaceRecoverInterruptedRequest) -> dict: from chipcompiler.runtime.recovery import recover_interrupted_operation diff --git a/docs/rpc-guide.md b/docs/rpc-guide.md index e8225fb8f..b098a6488 100644 --- a/docs/rpc-guide.md +++ b/docs/rpc-guide.md @@ -60,9 +60,11 @@ first-slice method list: The result includes `version`, `eccVersion`, and `capabilities`. -Default `ecc rpc serve --stdio` capabilities do not include persistent DB -methods. When `--persistent-db` is enabled, `rpc.hello` also advertises -`db.ensure` and `db.release`. +Default `ecc rpc serve --stdio` capabilities include Candidate methods +(`candidate.capabilities`, `candidate.rerun`, `candidate.resume`) composed +onto the generic runtime. They do not include persistent DB methods. When +`--persistent-db` is enabled, `rpc.hello` also advertises `db.ensure` and +`db.release`. ## Open A Workspace @@ -125,6 +127,32 @@ parameters, and optional input files: If `filelist` is omitted and `rtlList` is present, ECC writes a workspace-local filelist before creating the workspace. +## Derive A Workspace + +`workspace.derive` copies an existing workspace into a new directory with a +fresh identity: a new Engineering Snapshot (`workspaceRevision` 1, cause +`workspace.derived`), an empty runtime command ledger, and no inherited +workspace command records. The source directory stays read-only and +byte-identical. `resetFromStep` is optional; empty resets the whole flow for a +rerun, while a step name resets only that step and its flow suffix. + +```json +{ + "jsonrpc": "2.0", + "method": "workspace.derive", + "params": { + "directory": "/path/to/gcd", + "targetDirectory": "/path/to/gcd-rerun", + "resetFromStep": "Floorplan", + "commandId": "derive-1" + }, + "id": "derive-1" +} +``` + +The result has the same shape as `workspace.open` (`workspaceId`, +`workspaceRevision`, `directory`) with the derived workspace id. + ## Inspect A Workspace Use the returned `workspaceId` to inspect session state: @@ -166,6 +194,7 @@ first-slice mutation methods are: - `workspace.refresh_config` - `workspace.sync_config` - `workspace.reset_flow` +- `workspace.derive` - `flow.run` - `flow.run_step` - `workspace.close` diff --git a/ecc.spec b/ecc.spec index 3cd348902..0f1dba9b5 100644 --- a/ecc.spec +++ b/ecc.spec @@ -346,7 +346,6 @@ if BUNDLE_MODE == "onedir": upx=False, name="ecc", ) - ecc_exe_path = Path(coll.name) / "ecc" else: exe = EXE( pyz, @@ -361,8 +360,3 @@ else: console=True, codesign_identity=CODESIGN_IDENTITY, ) - ecc_exe_path = Path(exe.name) - -agent_exe_path = ecc_exe_path.with_name(f"ecc-agent-rpc{ecc_exe_path.suffix}") -agent_exe_path.unlink(missing_ok=True) -os.link(ecc_exe_path, agent_exe_path) diff --git a/packaging/run_ecc.py b/packaging/run_ecc.py index 601305735..59585adf7 100644 --- a/packaging/run_ecc.py +++ b/packaging/run_ecc.py @@ -1,7 +1,6 @@ import multiprocessing import os import sys -from pathlib import Path def _configure_pyinstaller_runtime() -> None: @@ -11,10 +10,8 @@ def _configure_pyinstaller_runtime() -> None: def main() -> int | None: - if Path(sys.argv[0]).stem == "ecc-agent-rpc": - from agent.rpc_server import main as entrypoint - else: - from chipcompiler.cli.main import main as entrypoint + from chipcompiler.cli.main import main as entrypoint + return entrypoint() diff --git a/pyproject.toml b/pyproject.toml index 2fdc291a9..a16350931 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ dependencies = [ "uvicorn>=0.27", ] scripts.ecc = "chipcompiler.cli.main:main" -scripts.ecc-agent-rpc = "agent.rpc_server:main" [dependency-groups] dev = [ diff --git a/test/engine/test_workspace_derive.py b/test/engine/test_workspace_derive.py new file mode 100644 index 000000000..fa5d5c71a --- /dev/null +++ b/test/engine/test_workspace_derive.py @@ -0,0 +1,37 @@ +import pytest + +from chipcompiler.engine.workspace_derive import derive_workspace +from chipcompiler.engine.workspace_lifecycle import WorkspaceLifecycleError + + +def test_derive_rejects_existing_target(tmp_path): + source = tmp_path / "source" + (source / "home").mkdir(parents=True) + (source / "home" / "engineering-snapshot.json").write_text("{}", encoding="utf-8") + + with pytest.raises(WorkspaceLifecycleError) as excinfo: + derive_workspace(source, tmp_path) + + assert excinfo.value.code == "workspace_exists" + + +def test_derive_rejects_source_without_engineering_snapshot(tmp_path): + source = tmp_path / "source" + (source / "home").mkdir(parents=True) + + with pytest.raises(WorkspaceLifecycleError) as excinfo: + derive_workspace(source, tmp_path / "target") + + assert excinfo.value.code == "workspace_invalid" + + +def test_derive_rejects_target_inside_source(tmp_path): + source = tmp_path / "source" + (source / "home").mkdir(parents=True) + (source / "home" / "engineering-snapshot.json").write_text("{}", encoding="utf-8") + + with pytest.raises(WorkspaceLifecycleError) as excinfo: + derive_workspace(source, source / "nested" / "target") + + assert excinfo.value.code == "workspace_invalid" + assert not (source / "nested").exists() diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py index 3eb6f16cc..1cdf787ac 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -10,6 +10,7 @@ def test_ecc_console_script_in_pyproject(self): with open(pyproject, "rb") as f: data = tomllib.load(f) assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main" + assert "ecc-agent-rpc" not in data["project"]["scripts"] def test_pyinstaller_spec_collects_jsonrpcserver_data_files(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) @@ -34,7 +35,7 @@ def test_pyinstaller_spec_filters_payloads_before_analysis(self): assert datas_filter_index < analysis_index assert binaries_filter_index < analysis_index - def test_pyinstaller_spec_reuses_ecc_executable_for_agent_rpc(self): + def test_pyinstaller_spec_publishes_exactly_one_ecc_executable(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) spec_path = os.path.join(project_root, "ecc.spec") @@ -42,7 +43,8 @@ def test_pyinstaller_spec_reuses_ecc_executable_for_agent_rpc(self): source = f.read() assert source.count(" = Analysis(") == 1 - assert "os.link(ecc_exe_path, agent_exe_path)" in source + assert "ecc-agent-rpc" not in source + assert "agent_exe_path" not in source def test_pyinstaller_spec_collects_doc_guides(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/test/packaging/test_run_ecc.py b/test/packaging/test_run_ecc.py index 80c8213e6..2e335ad03 100644 --- a/test/packaging/test_run_ecc.py +++ b/test/packaging/test_run_ecc.py @@ -1,35 +1,11 @@ -import importlib.util -import os -import sys from pathlib import Path -import agent.rpc_server - -def _load_entrypoint_module(): - project_root = Path(__file__).parents[2] - module_path = project_root / "packaging" / "run_ecc.py" - spec = importlib.util.spec_from_file_location("ecc_packaged_entrypoint", module_path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_agent_rpc_alias_selects_agent_entrypoint(monkeypatch): - module = _load_entrypoint_module() - calls = [] - - monkeypatch.setattr(sys, "argv", [os.path.join("dist", "ecc-agent-rpc")]) - monkeypatch.setattr(agent.rpc_server, "main", lambda: calls.append("agent") or 7) - - assert module.main() == 7 - - assert calls == ["agent"] - - -def test_packaged_entrypoint_propagates_exit_code(): +def test_packaged_entrypoint_has_a_single_ecc_cli(): project_root = Path(__file__).parents[2] source = (project_root / "packaging" / "run_ecc.py").read_text() + assert "ecc-agent-rpc" not in source + assert "agent.rpc_server" not in source + assert "from chipcompiler.cli.main import main as entrypoint" in source assert "raise SystemExit(main())" in source diff --git a/test/runtime/test_methods.py b/test/runtime/test_methods.py index b5a0a27d4..a5476b904 100644 --- a/test/runtime/test_methods.py +++ b/test/runtime/test_methods.py @@ -23,6 +23,7 @@ def test_runtime_method_registry_contains_current_methods_once(): "project.manifest.mutate", "workspace.create", "workspace.open", + "workspace.derive", "workspace.binding_requirement", "workspace.update", "workspace.configuration.update", diff --git a/test/runtime/test_requests.py b/test/runtime/test_requests.py index 93079b6c4..c61aaa79a 100644 --- a/test/runtime/test_requests.py +++ b/test/runtime/test_requests.py @@ -19,6 +19,7 @@ RequestValidationError, WorkspaceCloseRequest, WorkspaceCreateRequest, + WorkspaceDeriveRequest, WorkspaceExportSignoffRequest, WorkspaceIdRequest, WorkspaceInfoRequest, @@ -76,6 +77,11 @@ def test_workspace_create_maps_camel_case_fields_and_preserves_pdk_json(): ("method", "params", "request_type"), [ ("workspace.open", {"directory": "/work/ws"}, WorkspaceOpenRequest), + ( + "workspace.derive", + {"directory": "/work/ws", "targetDirectory": "/work/ws-copy"}, + WorkspaceDeriveRequest, + ), ("workspace.close", {"workspaceId": "ws-1"}, WorkspaceCloseRequest), ("workspace.home", {"workspaceId": "ws-1"}, WorkspaceIdRequest), ("workspace.refresh_config", {"workspaceId": "ws-1"}, WorkspaceIdRequest), diff --git a/test/runtime/test_server.py b/test/runtime/test_server.py index 7b275bcf0..8ca2ef042 100644 --- a/test/runtime/test_server.py +++ b/test/runtime/test_server.py @@ -25,6 +25,9 @@ def create_workspace(self, _request): def open_workspace(self, _request): raise AssertionError("unexpected open_workspace call") + def derive_workspace(self, _request): + raise AssertionError("unexpected derive_workspace call") + def close_workspace(self, _request): raise AssertionError("unexpected close_workspace call") diff --git a/test/runtime/test_stdio_server.py b/test/runtime/test_stdio_server.py index 73e5c0067..8ef146e99 100644 --- a/test/runtime/test_stdio_server.py +++ b/test/runtime/test_stdio_server.py @@ -170,6 +170,11 @@ def test_rpc_stdio_subprocess_smoke(): assert completed.returncode == 0 responses = _decode_output(completed.stdout) assert [response["id"] for response in responses] == [1, 2, 3] + capabilities = responses[0]["result"]["capabilities"] + assert "candidate.capabilities" in capabilities + assert "candidate.rerun" in capabilities + assert "candidate.resume" in capabilities + assert "agent.runtime_preflight" not in capabilities assert responses[1]["result"] == {"ok": True} diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index d335a6b2c..83d0c2885 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -14,6 +14,7 @@ FlowRunStepRequest, OperationStartFlowRequest, WorkspaceCreateRequest, + WorkspaceDeriveRequest, WorkspaceIdRequest, WorkspaceInfoRequest, WorkspaceOpenRequest, @@ -1780,3 +1781,330 @@ def test_build_workspace_step_for_info_forwards_db_from_any_predecessor(tmp_path assert next_step.input.db == Path(db_value) else: assert next_step.input.db is None + + +_DERIVE_SOURCE_SNAPSHOT_ID = "source-workspace" + + +class _DeriveFlowRecord: + def __init__(self, step): + self._step = step + + def update(self, values): + self._step.update(values) + + +class _DeriveFlow: + def __init__(self, workspace): + from chipcompiler.data.step import step_storage_name + + self.workspace = workspace + self.workspace_steps = [] + for step in workspace.flow.data.get("steps", []): + step_dir = Path(workspace.directory) / ( + f"{step_storage_name(step['name'], step['tool'])}_{step['tool']}" + ) + self.workspace_steps.append( + SimpleNamespace( + name=step["name"], + tool=step["tool"], + directory=step_dir, + output={"dir": step_dir / "output"}, + data={}, + feature={}, + analysis={}, + report={}, + log={}, + subflow=None, + checklist=None, + ) + ) + + def get_step(self, name, tool): + for step in self.workspace.flow.data.get("steps", []): + if step.get("name") == name and step.get("tool") == tool: + return _DeriveFlowRecord(step) + return None + + def save(self): + Path(self.workspace.flow.path).write_text( + json.dumps(self.workspace.flow.data), encoding="utf-8" + ) + + +def _make_derive_source(tmp_path): + source = (tmp_path / "source-ws").resolve() + home = source / "home" + steps = [ + {"name": "Synthesis", "tool": "yosys", "state": "Success", "runtime": "10s"}, + {"name": "Floorplan", "tool": "ecc", "state": "Success", "runtime": "20s"}, + {"name": "Route", "tool": "ecc", "state": "Success", "runtime": "30s"}, + ] + (source / "Synthesis_yosys" / "output").mkdir(parents=True) + (source / "Synthesis_yosys" / "output" / "synth.v").write_text("verilog", encoding="utf-8") + (source / "Floorplan_ecc" / "output").mkdir(parents=True) + (source / "Floorplan_ecc" / "output" / "fp.png").write_text("layout", encoding="utf-8") + (source / "Route_ecc" / "output").mkdir(parents=True) + home.mkdir(parents=True) + (home / "flow.json").write_text( + json.dumps({"path": str(home / "flow.json"), "steps": steps}), encoding="utf-8" + ) + (home / "home.json").write_text( + json.dumps( + { + "parameters": str(home / "params.toml"), + "flow": str(home / "flow.json"), + "layout": str(source / "Floorplan_ecc" / "output" / "fp.png"), + "checklist": str(home / "checklist.json"), + "metrics": { + "instances dist.": str(source / "Synthesis_yosys" / "output" / "dist.png"), + "pin dist.": str(source / "Route_ecc" / "output" / "pin.png"), + }, + } + ), + encoding="utf-8", + ) + (home / "params.toml").write_text("", encoding="utf-8") + (home / "engineering-snapshot.json").write_text( + json.dumps( + { + "schemaVersion": 2, + "workspaceId": _DERIVE_SOURCE_SNAPSHOT_ID, + "workspaceRevision": 3, + "cause": "workspace.updated", + } + ), + encoding="utf-8", + ) + (home / "workspace-commands.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "commands": { + "cmd-0": { + "fingerprint": "old", + "result": { + "workspaceId": _DERIVE_SOURCE_SNAPSHOT_ID, + "workspaceRevision": 3, + }, + } + }, + } + ), + encoding="utf-8", + ) + (home / "runtime-commands.json").write_text(json.dumps({"operations": []}), encoding="utf-8") + (home / "checklist.json").write_text( + json.dumps( + { + "schema_version": 3, + "kind": "signoff_checklist", + "checker_revision": "signoff-v1", + "generated_at": "2026-09-16T00:00:00Z", + "status": "ready", + "summary": {"passed": 2, "blocked": 1, "attention": 0, "unavailable": 0}, + "checklist": [ + { + "step": "Synthesis", + "category": "report", + "title": "Synth check", + "owner": "checklist", + "policy": "warn", + "state": "pass", + }, + { + "step": "Floorplan", + "category": "report", + "title": "FP check", + "owner": "checklist", + "policy": "warn", + "state": "pass", + }, + { + "step": "Route", + "category": "report", + "title": "Route check", + "owner": "checklist", + "policy": "block", + "state": "failed", + }, + ], + } + ), + encoding="utf-8", + ) + return source + + +def _install_derive_mocks(monkeypatch): + def fake_load_workspace(directory): + directory = Path(directory) + flow_path = directory / "home" / "flow.json" + home_path = directory / "home" / "home.json" + workspace = SimpleNamespace( + directory=directory.resolve(), + design=SimpleNamespace(name="gcd"), + flow=SimpleNamespace( + path=flow_path, + data=json.loads(flow_path.read_text(encoding="utf-8")), + ), + parameters=SimpleNamespace(data={}, path=None), + home=SimpleNamespace( + path=home_path, + data=json.loads(home_path.read_text(encoding="utf-8")), + ), + ) + + def save(home=workspace.home): + home.path.write_text(json.dumps(home.data), encoding="utf-8") + + workspace.home.save = save + return workspace + + def fake_prepare_workspace_for_rerun(workspace, engine_flow, **_kwargs): + for step in workspace.flow.data.get("steps", []): + step.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0, "info": {}}) + Path(workspace.flow.path).write_text(json.dumps(workspace.flow.data), encoding="utf-8") + checklist_path = Path(workspace.directory) / "home" / "checklist.json" + workspace.home.data["checklist"] = str(checklist_path) + workspace.home.data["layout"] = "" + workspace.home.data["metrics"] = {} + workspace.home.save() + checklist_path.write_text( + json.dumps({"path": str(checklist_path), "checklist": []}), encoding="utf-8" + ) + + monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", fake_prepare_workspace_for_rerun + ) + monkeypatch.setattr( + "chipcompiler.runtime.workspace_api.build_flow_for_workspace", + lambda workspace, **kwargs: _DeriveFlow(workspace), + ) + + +def _tree_digest(root: Path): + import hashlib + + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def test_derive_workspace_returns_fresh_identity_and_preserves_source(monkeypatch, tmp_path): + source = _make_derive_source(tmp_path) + _install_derive_mocks(monkeypatch) + source_digest = _tree_digest(source) + target = tmp_path / "derived-ws" + api = WorkspaceRuntimeApi() + + result = api.derive_workspace( + WorkspaceDeriveRequest( + directory=str(source), + target_directory=str(target), + command_id="cmd-derive", + ) + ) + + assert _tree_digest(source) == source_digest + assert result["directory"] == str(target.resolve()) + assert result["workspaceId"] != _DERIVE_SOURCE_SNAPSHOT_ID + assert result["workspaceRevision"] == 1 + assert not (target / "home" / "runtime-commands.json").exists() + snapshot = json.loads((target / "home" / "engineering-snapshot.json").read_text("utf-8")) + assert snapshot["workspaceId"] == result["workspaceId"] + assert snapshot["workspaceRevision"] == 1 + assert snapshot["cause"] == "workspace.derived" + commands = json.loads((target / "home" / "workspace-commands.json").read_text("utf-8")) + assert list(commands["commands"]) == ["cmd-derive"] + assert commands["commands"]["cmd-derive"]["result"]["workspaceId"] == result["workspaceId"] + flow = json.loads((target / "home" / "flow.json").read_text("utf-8")) + assert all(step["state"] == "Unstart" for step in flow["steps"]) + checklist = json.loads((target / "home" / "checklist.json").read_text("utf-8")) + assert checklist["checklist"] == [] + + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(target))) + assert opened == { + "workspaceId": result["workspaceId"], + "workspaceRevision": 1, + "directory": str(target.resolve()), + } + + +def test_derive_workspace_resets_only_step_suffix(monkeypatch, tmp_path): + source = _make_derive_source(tmp_path) + _install_derive_mocks(monkeypatch) + source_digest = _tree_digest(source) + target = tmp_path / "derived-ws" + api = WorkspaceRuntimeApi() + + result = api.derive_workspace( + WorkspaceDeriveRequest( + directory=str(source), + target_directory=str(target), + reset_from_step="Floorplan", + ) + ) + + assert _tree_digest(source) == source_digest + assert result["workspaceRevision"] == 1 + assert not (target / "home" / "runtime-commands.json").exists() + + flow = json.loads((target / "home" / "flow.json").read_text("utf-8")) + states = {step["name"]: step["state"] for step in flow["steps"]} + assert states == {"Synthesis": "Success", "Floorplan": "Unstart", "Route": "Unstart"} + assert (target / "Synthesis_yosys" / "output" / "synth.v").read_text("utf-8") == "verilog" + assert list((target / "Floorplan_ecc" / "output").iterdir()) == [] + assert list((target / "Route_ecc" / "output").iterdir()) == [] + + home = json.loads((target / "home" / "home.json").read_text("utf-8")) + assert home["layout"] == "" + assert home["metrics"] == { + "instances dist.": str(target.resolve() / "Synthesis_yosys" / "output" / "dist.png") + } + + checklist = json.loads((target / "home" / "checklist.json").read_text("utf-8")) + assert [item["step"] for item in checklist["checklist"]] == ["Synthesis"] + assert checklist["summary"] == {"passed": 1, "blocked": 0, "attention": 0, "unavailable": 0} + + snapshot = json.loads((target / "home" / "engineering-snapshot.json").read_text("utf-8")) + assert snapshot["workspaceId"] == result["workspaceId"] + snapshot_states = {step["name"]: step["state"] for step in snapshot["flow"]["steps"]} + assert snapshot_states == states + + +def test_derive_workspace_rewrites_artifact_paths_before_snapshot_digest(monkeypatch, tmp_path): + source = _make_derive_source(tmp_path) + artifact_dir = source / "Synthesis_yosys" / "analysis" + artifact_dir.mkdir(parents=True) + (artifact_dir / "qor_metrics.json").write_text( + json.dumps( + { + "schema_version": 3, + "metrics": [], + "report_root": str(source / "Synthesis_yosys"), + } + ), + encoding="utf-8", + ) + _install_derive_mocks(monkeypatch) + target = tmp_path / "derived-ws" + api = WorkspaceRuntimeApi() + + result = api.derive_workspace( + WorkspaceDeriveRequest( + directory=str(source), + target_directory=str(target), + reset_from_step="Floorplan", + ) + ) + + derived_artifact = json.loads( + (target / "Synthesis_yosys" / "analysis" / "qor_metrics.json").read_text("utf-8") + ) + assert derived_artifact["report_root"] == str(target.resolve() / "Synthesis_yosys") + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(target))) + assert opened["workspaceId"] == result["workspaceId"]