diff --git a/.github/actions/build-pyinstaller-bundle/action.yml b/.github/actions/build-pyinstaller-bundle/action.yml index 5ba0309ec..04374fbab 100644 --- a/.github/actions/build-pyinstaller-bundle/action.yml +++ b/.github/actions/build-pyinstaller-bundle/action.yml @@ -76,6 +76,11 @@ runs: "$SMOKE_DIR/ecc" --help "$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" "$SMOKE_DIR/ecc" doc config --plain > /dev/null "$SMOKE_DIR/ecc" doc ug --lang cn --plain > /dev/null test -x "$SMOKE_DIR/_internal/torch/bin/torch_shm_manager" diff --git a/.gitignore b/.gitignore index 9f761a358..196697b91 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 diff --git a/agent/README.cn.md b/agent/README.cn.md new file mode 100644 index 000000000..b500137f3 --- /dev/null +++ b/agent/README.cn.md @@ -0,0 +1,67 @@ +# Candidate Runtime 入口说明 + +ECC 发布 `ecc` 可执行文件,并附带 `ecc-agent-rpc` 兼容别名(同一二进制的 +硬链接)。Candidate 方法通过内部 server composition 注册到统一的 +`ecc rpc serve --stdio` 运行时;`ecc-agent-rpc` 也直接进入该运行时。 + +```toml +scripts.ecc = "chipcompiler.cli.main:main" +scripts.ecc-agent-rpc = "chipcompiler.runtime.stdio_server:main" +``` + +桌面端或离线研究客户端启动该进程,通过标准输入发送请求,并从标准输出 +读取结果。这是通用 ECC JSON-RPC sidecar,也是 Candidate Execution 的唯一 +产品入口。 + +## `ecc rpc serve` 做什么 + +`chipcompiler/cli/commands/rpc.py` 调用 +[`stdio_server.main()`](../chipcompiler/runtime/stdio_server.py)。`main()` +保持很小,只完成启动职责: + +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`;Candidate +行为由 `AgentRuntimeServer`、`FlowAgentRuntimeApi` 及其隔离执行实现。 + +## 协议与能力 + +该进程使用带 `Content-Length` 头的 JSON-RPC 2.0 stdio 协议。标准输出只可写入 +协议帧,诊断和工具输出应写入标准错误或 workspace 日志,避免破坏客户端解码。 + +`rpc.hello` 返回的 capabilities 包含通用 runtime 方法,以及 Candidate 方法: + +- `workspace.extract_foundation`:提取已完成 workspace 的 foundation 数据; +- `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 rpc serve` 仅暴露已注册的 typed RPC 方法。它不接收自然语言计划、不选择 +优化参数,也不执行调用方提供的任意 shell 命令。候选操作仍由 Agent runtime 的 +参数校验、workspace 边界和执行回执约束。 + +启动 `AgentRuntimeServer` 时会调用 [`runtime_env.py`](runtime_env.py) 处理可选的 +打包 Sizer 运行时路径;这只准备运行环境,不代表一次 physical-design flow 已 +执行成功。实际客户端应先使用 `rpc.hello` 协商能力,并保留 operation 事件与 +workspace 产物作为执行证据。 + +## 维护约定 + +- 新增 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 new file mode 100644 index 000000000..e2012325a --- /dev/null +++ b/agent/README.md @@ -0,0 +1,152 @@ +# ECC Agent Runtime(`agent/`) + +`agent/` 是 ECC 的 **Flow Agent 运行时**:在 `chipcompiler` 标准 runtime 之上构建的 +受控、可审计的流程代理执行层。它通过内部 server composition 注册到唯一的 +`ecc rpc serve` JSON-RPC 入口,供 ECOS Studio 的 GUI 前端与 `ecos_agent` +受控优化后端驱动。产品中只发布 `ecc` 可执行文件,不再提供独立的 Agent sidecar。 + +它只做确定性的执行与证据记录:**决策不在这里**。`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`。 +6. **不改变普通 Flow**:普通 `workspace.*` 与 `flow.*` 继续使用通用 Workspace + Runtime API 和 Engine Flow;只有 Candidate Execution 在自己的 operation + 边界内构造 Candidate Flow。 + +## 代码结构 + +```text +agent/ +├── server.py # AgentRuntimeServer:在通用 RuntimeServer 上组合 Candidate 方法 +├── methods.py # Agent RPC 方法表(RuntimeMethodSpec 声明) +├── requests.py # RPC 请求模型(frozen dataclass + 校验) +├── workspace_api.py # FlowAgentRuntimeApi:Candidate 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 # 当前 workspace 的候选能力查询 + ├── 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 写出 +``` + +分层关系:`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`)中追加声明以下方法: + +| 方法 | 作用 | +| --- | --- | +| `workspace.extract_foundation` | 从 workspace 证据提取 foundation 数据表 | +| `candidate.capabilities` | 查询当前 workspace 的候选能力(受控 knob、后端需求) | +| `candidate.rerun` | 原子克隆候选 workspace 并在隔离 worker 中重跑目标阶段 | +| `candidate.resume` | 在原候选 workspace 上断点续跑失败的候选 | + +传输与分帧协议与基础 runtime 一致(`Content-Length` 分帧的 JSON-RPC 2.0), +详见 `docs/rpc-guide.md`。输入绑定、配置 materialization 和运行时预检保留为 +`candidate.rerun` 的内部步骤,不作为独立 RPC 方法。 + +## 候选重跑生命周期 + +一次受控候选评估的公开调用序列: + +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`,不影响 +源 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 表面或事件语义时,须 +同步该文档与两侧客户端。生产路径应通过 Electron Product Command 调用 +`ecc rpc serve`,而不是再启动第二个 Agent executable。 + +## 开发与测试 + +```bash +# 启动 stdio RPC 服务(Candidate 方法已组合进该入口) +uv run ecc rpc serve --stdio + +# 运行本目录测试(与 chipcompiler 的 test/ 互相独立) +uv run pytest agent/test + +# Lint 与格式 +uv run ruff check agent/ +uv run ruff format agent/ +``` + +测试按被测模块就近放置于 `agent/test/`;foundation 提取器相关的基线数据 +与其表格契约测试同样位于该目录。 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/candidate_resume.py b/agent/candidate_resume.py new file mode 100644 index 000000000..97faccd36 --- /dev/null +++ b/agent/candidate_resume.py @@ -0,0 +1,431 @@ +"""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_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, + _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, + _workspace_state_sha256, + candidate_operation_workspace_id, + run_candidate_steps_isolated, +) + + +def candidate_resume(api, request: CandidateResumeRequest) -> dict: + _validate_candidate_resume_request(request) + 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=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: _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: + # 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) + 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"]) + run_candidate_steps_isolated(flow, steps, 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 ( + 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") + + +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_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() + ): + 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, + parameter_card_sha256=request.parameter_card_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: + 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}" + ) 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") + 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: + 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("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 + ): + 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/params.toml", + "home/parameters.json", + "config/floorplan_ecc.json", + "config/cts_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: + 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: + 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/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/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/data/candidate_input_binding.py b/agent/data/candidate_input_binding.py index 1c0018b2c..0cc908eea 100644 --- a/agent/data/candidate_input_binding.py +++ b/agent/data/candidate_input_binding.py @@ -13,13 +13,33 @@ 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 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"), @@ -35,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, @@ -44,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) @@ -65,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, @@ -132,7 +163,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/data/candidate_materialization.py b/agent/data/candidate_materialization.py index 9322208c9..90994e23e 100644 --- a/agent/data/candidate_materialization.py +++ b/agent/data/candidate_materialization.py @@ -1,6 +1,9 @@ """Controlled, replayable config overlays for isolated ECC candidate workspaces.""" import math +import re +import shutil +from copy import deepcopy from pathlib import Path from typing import Any @@ -37,11 +40,26 @@ 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) + 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, @@ -51,6 +69,7 @@ def materialize_candidate_config( config_paths, before_hashes, after_hashes, + snapshots, ) write_json_atomic(_receipt_path(workspace), receipt) return receipt @@ -64,30 +83,30 @@ def reapply_materialized_candidate_config( if not receipt_path.exists(): return None receipt = _read_receipt(receipt_path) - if receipt["target"]["step"] != target_step: + if receipt["target_step"] != target_step: return None - normalized_patch = receipt["patch"] - knobs = _resolve_knobs(target_step, normalized_patch, workspace) - configs, config_paths, before_hashes = _load_configs(workspace, knobs) - _apply_patch(configs, knobs, normalized_patch) - after_hashes = _write_configs(workspace, configs, config_paths) - updated = _build_receipt( - workspace, - target_step, - receipt["candidate_id"], - normalized_patch, - knobs, - config_paths, - before_hashes, - after_hashes, - ) - 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 = _load_parameters_config(config_path) + 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: @@ -111,6 +130,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 materialized values.""" + 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]], @@ -271,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( @@ -293,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 @@ -364,6 +435,42 @@ def _write_configs( return hashes +def _write_config_snapshots( + workspace: Any, + candidate_id: str, + 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") + 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) + 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, @@ -373,6 +480,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 = [ { @@ -393,6 +501,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 @@ -412,15 +521,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 materialization receipt to the 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"]) - return receipt["candidate_id"] + _verify_config_snapshot_hashes(workspace, receipt["snapshots"]) + return receipt def _read_receipt(path: Path) -> dict[str, Any]: @@ -460,10 +577,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")) + _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") @@ -481,11 +658,36 @@ 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(_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( + "candidate materialization patch did not change config" + ) + + +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].startswith("sha256:") - for key in ("before_sha256", "after_sha256") + isinstance(entry[key], str) and entry[key] for key in ("before_ref", "after_ref") ): - raise CandidateMaterializationError("candidate materialization config hash is invalid") + raise CandidateMaterializationError("candidate config snapshot ref is invalid") + 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") def _verify_materialized_config_hashes(workspace: Any, configs: list[dict[str, Any]]) -> None: @@ -502,6 +704,31 @@ 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 _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/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/data/floorplan_parameter_observer.py b/agent/data/floorplan_parameter_observer.py new file mode 100644 index 000000000..ec1e98c2f --- /dev/null +++ b/agent/data/floorplan_parameter_observer.py @@ -0,0 +1,113 @@ +"""Agent-owned observation of the two controlled floorplan parameters.""" + +import json +import math +from contextlib import ExitStack, contextmanager +from functools import partial +from pathlib import Path +from threading import RLock + +from .candidate_artifacts import sha256_path +from .parameter_runtime_observer import _patch_method + +FLOORPLAN_OBSERVER_REVISION = "ecc.agent.floorplan_parameter_observer.v2" +FLOORPLAN_KNOBS = frozenset({"floorplan.core_util", "floorplan.aspect_ratio"}) +# ponytail: serialize same-process observers; use thread-local hooks if throughput matters. +_OBSERVATION_LOCK = RLock() + + +@contextmanager +def capture_floorplan(patch): + from chipcompiler.tools.ecc.module import ECCToolsModule + + 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)) + yield boundary + + +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 result + + +def _observe_floorplan_run(boundary, original, module, *args, **kwargs): + boundary["run_fp_call_count"] += 1 + result = original(module, *args, **kwargs) + boundary["run_fp_completed"] = result is not False + return result + + +def build_floorplan_report(patch, boundary, feature_path, *, engine_succeeded): + knob_id = patch["knob_id"] + 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 + ) + 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, + "written_value": patch["value"], + "tool": { + "name": "ECC-Floorplan", + "revision": FLOORPLAN_OBSERVER_REVISION, + "source_sha256": sha256_path(Path(__file__)), + }, + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, + } + + +def _read_json(path): + if path is None: + return {} + try: + value = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +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): + if type(value) is int: + return value + return value if type(value) is float and math.isfinite(value) else None 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_application_receipt.py b/agent/data/parameter_application_receipt.py new file mode 100644 index 000000000..231d6137c --- /dev/null +++ b/agent/data/parameter_application_receipt.py @@ -0,0 +1,95 @@ +"""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. +""" + +import hashlib +import json +import math +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") + normalized_tool = dict(tool) + 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") + 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.v2", + "receipt_id": receipt_id, + "tool": normalized_tool, + "context": dict(context), + "requested": dict(requested), + "materialization": normalized_materialization, + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, + } + 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 + + +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/data/parameter_runtime_observer.py b/agent/data/parameter_runtime_observer.py new file mode 100644 index 000000000..c7a24c270 --- /dev/null +++ b/agent/data/parameter_runtime_observer.py @@ -0,0 +1,349 @@ +"""Agent-owned observation of the five controlled DREAMPlace parameters.""" + +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 sha256_path, write_json_atomic +from .observed_callable import ObservedCallable + +DREAMPLACE_OBSERVER_REVISION = "ecc.agent.dreamplace_parameter_observer.v3" +RUNTIME_REPORT_REF = "analysis/parameter_runtime_report.v2.json" +DREAMPLACE_KNOBS = frozenset( + { + "place.target_density", + "place.target_overflow", + "place.cell_padding_x", + "place.routability_opt", + "place.density_weight", + } +) +# ponytail: serialize same-process observers; use thread-local hooks if throughput matters. +_OBSERVATION_LOCK = RLock() +_MISSING = object() + + +@dataclass +class DreamplaceRecorder: + patch: dict[str, Any] + engine: Any = None + model: Any = None + ppa: dict[str, Any] = field(default_factory=dict) + probe: dict[str, Any] = field( + default_factory=lambda: { + "density_operator_call_count": 0, + "initialization_count": 0, + "place_object_count": 0, + "routability_branch_round_count": 0, + "placement_completed": False, + } + ) + + +def run_with_parameter_observation(workspace, step, materialization, invoke): + if materialization is None: + return invoke() + patch = materialization["patch"][0] + if patch["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, + 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, + invoke, + lambda succeeded: build_floorplan_report( + patch, + boundary, + step_path(step, "feature", "db"), + engine_succeeded=succeeded, + ), + ) + + +def _invoke_and_record(workspace, invoke, build_report): + 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, build_report, *, engine_succeeded): + try: + 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: + 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.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.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) -> 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) + replacement = observed if isinstance(owner, type) else ObservedCallable(observed, original) + setattr(owner, name, replacement) + stack.callback(_restore_attribute, owner, name, previous) + + +def _restore_attribute(owner, name, previous): + if previous is _MISSING: + delattr(owner, name) + else: + setattr(owner, name, previous) + + +def _observe_placement_run(recorder, original, engine, *args, **kwargs): + 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.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): + 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"] = { + "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): + 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, 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"): + if callable(getattr(operations, name, None)): + _patch_method( + stack, operations, name, partial(_observe_density_operator, recorder, model) + ) + elif knob_id == "place.density_weight": + _patch_method( + stack, + model, + "initialize_density_weight", + partial(_observe_density_weight_initialization, 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, model, original, *args, **kwargs): + result = original(*args, **kwargs) + recorder.probe["density_operator_call_count"] += 1 + 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) + 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 result + + +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 {} + 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"] + # 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)) + 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, + "written_value": patch["value"], + "tool": { + "name": "DREAMPlace", + "revision": DREAMPLACE_OBSERVER_REVISION, + "source_sha256": sha256_path(Path(__file__)), + }, + "actual_value": actual, + "status": status, + "reason": reason, + "observation": observation, + } + + +def _scalar_value(value): + with suppress(AttributeError, RuntimeError, TypeError, ValueError): + value = value.item() + if type(value) in {bool, int}: + return value + return value if type(value) is float and math.isfinite(value) else None diff --git a/agent/engine.py b/agent/engine.py index 44d725f2b..3ebb49a3e 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -1,3 +1,4 @@ +import logging import os import time import traceback @@ -5,13 +6,34 @@ from chipcompiler.data import StateEnum, WorkspaceStep from chipcompiler.engine.flow import EngineFlow -from chipcompiler.engine.flow_completion import normalize_legacy_terminal_state +from chipcompiler.engine.flow_completion import ( + normalize_legacy_terminal_state, +) +from chipcompiler.engine.flow_completion import ( + notify_flow_observer as _notify_flow_observer, +) 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 from .tools import run_step as run_agent_step +def _wait_for_step_rendered(observer, workspace_step: WorkspaceStep, state: StateEnum) -> bool: + if observer is None or state != StateEnum.Success: + return True + callback = getattr(observer, "wait_for_step_rendered", None) + if not callable(callback): + return True + try: + return bool(callback(workspace_step, state)) + except Exception: + # Fail-open: observer bugs must not invalidate successful tool results. + logging.getLogger(__name__).exception("flow observer render gate failed") + return True + + class AgentEngineFlow(EngineFlow): def build_default_steps(self): super().build_default_steps() @@ -22,7 +44,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: @@ -33,6 +61,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 normalize_legacy_terminal_state(self, workspace_step, step_tag) @@ -40,9 +69,15 @@ 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() + 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: + self.workspace._runtime_flow_observer = observer try: result = run_agent_step( workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine @@ -53,6 +88,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) @@ -63,6 +103,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: @@ -72,16 +119,26 @@ 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() - 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, ) @@ -151,4 +208,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/floorplan_mode.py b/agent/floorplan_mode.py new file mode 100644 index 000000000..c50edf17f --- /dev/null +++ b/agent/floorplan_mode.py @@ -0,0 +1,188 @@ +"""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 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 + 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/methods.py b/agent/methods.py index 14419ea22..e21798dae 100644 --- a/agent/methods.py +++ b/agent/methods.py @@ -4,9 +4,8 @@ from chipcompiler.runtime.requests import WorkspaceIdRequest from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, CandidateRerunRequest, + CandidateResumeRequest, WorkspaceExtractFoundationRequest, ) @@ -17,25 +16,20 @@ 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", request_model=CandidateRerunRequest, handler_name="candidate_rerun", ), + RuntimeMethodSpec( + method_name="candidate.resume", + request_model=CandidateResumeRequest, + handler_name="candidate_resume", + ), ) diff --git a/agent/plot.py b/agent/plot.py index 1d33b1736..2db03f5df 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,23 @@ 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() + + 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/requests.py b/agent/requests.py index 30be9d4a2..fef46f361 100644 --- a/agent/requests.py +++ b/agent/requests.py @@ -10,38 +10,42 @@ class WorkspaceExtractFoundationRequest: @dataclass(frozen=True) -class CandidateBindInputRequest: - workspace_id: str - target_step: str - source_step: str - candidate_id: str - - -@dataclass(frozen=True) -class CandidateMaterializeRequest: +class CandidateRerunRequest: workspace_id: str target_step: str + end_step: str candidate_id: str patch: list[dict[str, Any]] + execution_scope: str + idempotency_key: str + context_sha256: str + parameter_card_sha256: str + seed: int + parent_candidate_root_ref: str | None = None + floorplan_mode: str | None = None @dataclass(frozen=True) -class CandidateRerunRequest: +class CandidateResumeRequest: workspace_id: str - target_step: str - end_step: str candidate_id: str - patch: list[dict[str, Any]] - execution_scope: str + idempotency_key: str + context_sha256: str + parameter_card_sha256: str + seed: int _FIELD_ALIASES = { "workspaceId": "workspace_id", "targetStep": "target_step", "endStep": "end_step", - "sourceStep": "source_step", "candidateId": "candidate_id", "executionScope": "execution_scope", + "idempotencyKey": "idempotency_key", + "contextSha256": "context_sha256", + "parameterCardSha256": "parameter_card_sha256", + "parentCandidateRootRef": "parent_candidate_root_ref", + "floorplanMode": "floorplan_mode", } diff --git a/agent/runtime_env.py b/agent/runtime_env.py new file mode 100644 index 000000000..bd3da7804 --- /dev/null +++ b/agent/runtime_env.py @@ -0,0 +1,101 @@ +"""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 + +_SIZER_EXECUTABLES = ( + Path("bin") / "Sizer", + Path("build") / "src" / "Sizer", + Path("build") / "Sizer", + Path("Sizer"), +) + + +class SizerRuntimePreflightError(RuntimeError): + pass + + +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))) + + +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") + + 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) + 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: + 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 d5a1b0ef2..75ec01c59 100644 --- a/agent/server.py +++ b/agent/server.py @@ -1,10 +1,12 @@ 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 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 @@ -15,7 +17,11 @@ def __init__( *, persistent_db_enabled: bool = False, ): - super().__init__(api=api, persistent_db_enabled=persistent_db_enabled) + prepare_agent_runtime_environment() + super().__init__( + api=api or WorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled), + persistent_db_enabled=persistent_db_enabled, + ) self.agent_api = FlowAgentRuntimeApi(self.api) self._register_agent_methods() @@ -34,6 +40,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/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..bdf4c4237 --- /dev/null +++ b/agent/sta_parallel.py @@ -0,0 +1,205 @@ +"""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 +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 + + +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"}: + 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/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/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/data/test_candidate_materialization.py b/agent/test/data/test_candidate_materialization.py index 1d5b3f02c..9e826a06f 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,20 +205,244 @@ 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" +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_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") + 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( + ("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): + from chipcompiler.data.parameter import load_parameter, save_parameter + + workspace = _workspace(tmp_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 = 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 == 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): + 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( + 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( + 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"), [ @@ -305,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" @@ -318,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:") @@ -407,12 +646,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", ) @@ -420,7 +654,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 diff --git a/agent/test/test_candidate_resume.py b/agent/test/test_candidate_resume.py new file mode 100644 index 000000000..224b7d610 --- /dev/null +++ b/agent/test/test_candidate_resume.py @@ -0,0 +1,299 @@ +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_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") + + initial = _workspace_state_sha256(tmp_path) + (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: + 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.workspace_api._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, + parameter_card_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, + parameter_card_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, + parameter_card_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_engine.py b/agent/test/test_engine.py index 2e80a2dea..985844919 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,67 @@ 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 + + +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_floorplan_mode.py b/agent/test/test_floorplan_mode.py new file mode 100644 index 000000000..0f5058d44 --- /dev/null +++ b/agent/test/test_floorplan_mode.py @@ -0,0 +1,300 @@ +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, + "run_fp_completed": True, + "config_path": str(workspace.config["Floorplan"]), + }, + feature, + engine_succeeded=True, + ) + 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_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/test/test_parameter_receipt_artifacts.py b/agent/test/test_parameter_receipt_artifacts.py new file mode 100644 index 000000000..82acf83be --- /dev/null +++ b/agent/test/test_parameter_receipt_artifacts.py @@ -0,0 +1,431 @@ +import json +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, + _parameter_receipt_context, + _stable_hash, +) +from chipcompiler.runtime.workspace_api import RuntimeApiError + +HASH = "sha256:" + "a" * 64 +PRODUCER = Path(__file__).parents[1] / "data/parameter_runtime_observer.py" +TOOL = { + "name": "DREAMPlace", + "revision": "ecc.agent.dreamplace_parameter_observer.v3", + "source_sha256": sha256_path(PRODUCER), +} + + +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, + "written_value": written_value, + "tool": TOOL, + "schema_version": "tool.parameter_runtime_report.v2", + "status": "unknown", + "actual_value": None, + "reason": "Required runtime observation is unavailable.", + "observation": {}, + } + ), + 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" / "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.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").write_text("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") + 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, + knob_id="place.target_density", + written_value=0.85, + ) + request = SimpleNamespace( + candidate_id="candidate-1", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + parameter_card_sha256=HASH, + seed=17, + ) + + receipt = _candidate_parameter_receipt( + workspace, + request, + ".agent/candidates/candidate-1", + materialization, + parent_flow_sha256=HASH, + ) + + 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 + 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" + 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_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 / "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").write_text("top.v\nworker.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.glob("*.v"))]}) + sdc_sha256 = _stable_hash( + {"files": [sha256_path(path) for path in sorted(origin.glob("*.sdc"))]} + ) + filelist_sha256 = sha256_path(origin / "filelist") + 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_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, + 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": 1}], + context_sha256=HASH, + parameter_card_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", + knob_id="place.cell_padding_x", + written_value=200, + ) + receipt = _candidate_parameter_receipt( + 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_parameter_receipt_rejects_incomplete_materialization(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}]}), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-1", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + parameter_card_sha256=HASH, + seed=17, + ) + + with pytest.raises(CandidateMaterializationError): + _candidate_parameter_receipt( + SimpleNamespace(directory=tmp_path), + request, + ".agent/candidates/candidate-1", + materialization, + parent_flow_sha256=HASH, + ) + + +def test_candidate_receipt_preserves_minimal_runtime_observation( + 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 = { + "target_density": 0.8, + "density_tensor_value": 0.8, + "density_operator_call_count": 4, + "utilization_floor": 0.8, + } + (analysis / "parameter_runtime_report.v2.json").write_text( + json.dumps( + { + "schema_version": "tool.parameter_runtime_report.v2", + "knob_id": "place.target_density", + "written_value": 0.2, + "tool": TOOL, + "status": "effective", + "actual_value": 0.8, + "reason": None, + "observation": observation, + } + ), + encoding="utf-8", + ) + request = SimpleNamespace( + candidate_id="candidate-floor", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.2}], + context_sha256=HASH, + parameter_card_sha256=HASH, + seed=17, + ) + + receipt = _candidate_parameter_receipt( + workspace, + request, + ".agent/candidates/candidate-floor", + materialization, + parent_flow_sha256=HASH, + ) + + 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( + 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, + ) + _write_unknown_runtime_report( + tmp_path / "analysis", knob_id="place.density_weight", written_value=0.001 + ) + request = SimpleNamespace( + candidate_id="candidate-density", + target_step="place", + patch=[{"knob_id": "place.target_density", "value": 0.85}], + context_sha256=HASH, + parameter_card_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", + written_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, + parameter_card_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", + written_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, + parameter_card_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( + 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={ + "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 new file mode 100644 index 000000000..7fb7ff171 --- /dev/null +++ b/agent/test/test_parameter_runtime_observer.py @@ -0,0 +1,257 @@ +import json +from contextlib import ExitStack +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, + _observe_cell_padding, + _observe_native_model, + _observe_placement_call, + _patch_method, +) + + +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 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_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) + + +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)) + + def place(engine): + engine.metrics = {"overflow": [0.7, 0.08]} + + _observe_placement_call(recorder, place, engine) + report = _build_dreamplace_report( + recorder.patch, + engine, + recorder.ppa, + recorder.probe, + engine_succeeded=False, + ) + 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}, + { + "place_object_count": 1, + "routability_branch_round_count": rounds, + "placement_completed": completed, + }, + succeeded=completed, + ) + assert (report["status"], report["actual_value"]) == (status, actual) + + +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_branch_round_count": 0, + "placement_completed": 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 + + +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 apply(db, _params): + db.cell_padding_x = 200 + + _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} + + +@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": mode, "die_util": {"utilization": 0.8, "aspect_ratio": 1.0}}} + ) + ) + feature = tmp_path / "feature.json" + feature.write_text( + json.dumps( + { + "Design Layout": { + "core_usage": 0.79, + "core_bounding_width": 40.0, + "core_bounding_height": 20.0, + } + } + ) + ) + report = build_floorplan_report( + {"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, + ) + + +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")) + assert Owner().run() == ("original", "observed") + results = [] + thread = Thread(target=lambda: results.append(Owner().run())) + thread.start() + thread.join() + assert results == ["original"] + assert Owner.run is original + + +def test_scoped_callable_preserves_operator_methods(): + class Operation: + def __call__(self): + return "original" + + def reset(self): + 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")) + assert owner.density_op() == ("original", "observed") + assert owner.density_op.reset() == "reset" + assert owner.density_op is original + + +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_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_requests.py b/agent/test/test_requests.py index a27f6ff5e..5bd5a43cb 100644 --- a/agent/test/test_requests.py +++ b/agent/test/test_requests.py @@ -1,25 +1,79 @@ +import json +import subprocess +import sys +from types import SimpleNamespace + 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 +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() == ( - "workspace.extract_foundation", - "candidate.export_capabilities", - "candidate.bind_input", - "candidate.materialize", - "candidate.rerun", - ) + +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_methods_keep_the_public_candidate_rpc_names(): + assert agent_method_names() == PUBLIC_CANDIDATE_METHODS def test_agent_runtime_server_registers_isolated_methods(): server = AgentRuntimeServer() 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): + calls = [] + monkeypatch.setattr( + "agent.server.prepare_agent_runtime_environment", + lambda: calls.append(True), + ) + + AgentRuntimeServer() + + assert calls == [True] + + +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, **_kwargs: flow, + ) + server = AgentRuntimeServer() + + result = server.agent_api._build_flow(SimpleNamespace()) + + assert result is flow def test_agent_request_normalizes_camel_case_fields(): @@ -32,6 +86,11 @@ def test_agent_request_normalizes_camel_case_fields(): "candidateId": "candidate-1", "patch": [], "executionScope": "full_flow", + "idempotencyKey": "episode-1.intervention-1", + "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, + "seed": 17, + "parentCandidateRootRef": ".agent/candidates/candidate-0", }, ) @@ -42,9 +101,86 @@ def test_agent_request_normalizes_camel_case_fields(): candidate_id="candidate-1", patch=[], 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", ) +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_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, + "parameterCardSha256": PARAMETER_CARD_SHA256, + "seed": 17, + }, + ) + + assert request == CandidateResumeRequest( + workspace_id="workspace-1", + candidate_id="candidate-1", + idempotency_key="episode-1.resume-1", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=PARAMETER_CARD_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( + 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, + "parameterCardSha256": PARAMETER_CARD_SHA256, + }, + ) + + def test_agent_request_rejects_duplicate_aliases(): with pytest.raises(RequestValidationError, match="duplicate field: workspace_id"): parse_agent_request_model( @@ -59,3 +195,64 @@ 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": "Harden", + "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", + "contextSha256": CONTEXT_SHA256, + "parameterCardSha256": PARAMETER_CARD_SHA256, + "seed": 17, + }, + } + ) + ) + ) + + assert response["error"] == { + "code": -32602, + "message": "invalid_request", + "data": {"message": "candidate rerun requires exactly one patch item"}, + } + + +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=(",", ":"))) + + 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/test/test_runtime.py b/agent/test/test_runtime.py new file mode 100644 index 000000000..d009b0d3a --- /dev/null +++ b/agent/test/test_runtime.py @@ -0,0 +1,183 @@ +import os +import shutil +import threading + +import pytest + +from agent.runtime_env import ( + SizerRuntimePreflightError, + preflight_sizer_runtime, + 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_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() + monkeypatch.setattr( + "chipcompiler.tools.ecc_dreamplace.utility.is_eda_exist", + lambda: True, + ) + + 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() + 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) + 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_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..3769b7f8f --- /dev/null +++ b/agent/test/test_sta_parallel.py @@ -0,0 +1,280 @@ +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_applies_to_every_workspace_sta_step(tmp_path, monkeypatch): + monkeypatch.setattr(sta.sys, "platform", "linux") + step = SimpleNamespace(tool="ecc", name="sta") + assert sta.sta_workers(step) == 2 + for value in ("1", "2", "4"): + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", 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(step) + step.name = "Harden" + assert sta.sta_workers(step) == 1 + + +def test_non_linux_keeps_serial_default(tmp_path, monkeypatch): + monkeypatch.setattr(sta.sys, "platform", "darwin") + monkeypatch.delenv("ECOS_AGENT_STA_WORKERS", raising=False) + step = SimpleNamespace(tool="ecc", name="sta") + assert sta.sta_workers(step) == 1 + monkeypatch.setenv("ECOS_AGENT_STA_WORKERS", "2") + with pytest.raises(ValueError, match="requires Linux"): + sta.sta_workers(step) + + +@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 c0fd7e495..4c494fe85 100644 --- a/agent/test/test_tools.py +++ b/agent/test/test_tools.py @@ -1,9 +1,17 @@ import json +import os +from contextlib import contextmanager 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 +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: @@ -11,6 +19,42 @@ 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 + + +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 + + 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 +90,92 @@ 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}) + 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 + 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) + 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.v2.json").read_text()) + assert report["tool"]["revision"] == "ecc.agent.dreamplace_parameter_observer.v3" + assert report["status"] == "effective" + assert report["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}) @@ -79,3 +209,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/test/test_workspace_api.py b/agent/test/test_workspace_api.py index f1106b931..7c6272cc8 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -1,10 +1,33 @@ +import json +import threading from pathlib import Path from types import SimpleNamespace +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 FlowAgentRuntimeApi, _candidate_step_artifact_dirs +from agent.workspace_api import ( + FlowAgentRuntimeApi, + _candidate_rerun_result, + _candidate_rerun_steps, + _candidate_source_step, + _candidate_step_artifact_dirs, + _candidate_step_range, + _create_candidate_workspace, + _materialize_candidate_rerun, + _preflight_candidate_steps, + _reject_workspace_symlinks, + build_agent_flow_for_workspace, +) 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 + +CONTEXT_SHA256 = "sha256:" + "a" * 64 def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path): @@ -18,53 +41,336 @@ 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_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": "Floorplan", "tool": "ecc", "state": "Success"}, - {"name": "place", "tool": "dreamplace", "state": "Success"}, - {"name": "CTS", "tool": "ecc", "state": "Success"}, + {"name": "place", "tool": "dreamplace"}, + {"name": "Timing optimization", "tool": "sizer"}, + {"name": "Harden", "tool": "ecc"}, ] } ), ) - 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): + 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": [ + {"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", + [ + ("Floorplan", "Floorplan"), + ("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", + "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") + + assert steps[0].name == expected_first + 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" + + +_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): + 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")], + ) + + flow = build_agent_flow_for_workspace(SimpleNamespace()) + + assert flow.added_steps == [("rtl2gds", "ecc", "Unstart")] + + +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"}, + {"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") + 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=flow_data, path=flow_path), + ) + for directory in ( + 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") - 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, *, 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 + + 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) - ), - ) - monkeypatch.setattr( - "agent.workspace_api.materialize_candidate_config", - lambda _ws, target, patch, candidate: calls.append( - ("materialize", target, patch, 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 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"] + 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", @@ -82,18 +388,39 @@ def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatc 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, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, ) ) - 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="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 duplicate["operationId"] == result["operationId"] + assert duplicate["deduplicated"] is True + terminal = _wait_for_terminal(api.ecc_api.operations, result["operationId"]) assert calls == [ ("bind", "place", "Floorplan", "candidate-1"), ( @@ -105,16 +432,597 @@ def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatc ("reapply", "place"), ("init", "place"), ("init", "CTS"), + ("init", "Harden"), ] - 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" + 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() + 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 json.loads( + (candidate_root / "config" / "dreamplace.json").read_text(encoding="utf-8") + ) == {"random_seed": 17, "target_density": 0.6} + 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()) + 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"} == { + "candidateId": "candidate-1", + "candidateManifestRef": candidate_manifest_ref, + "candidateRootRef": candidate_root_ref, + "endStep": "Harden", + "executionScope": "full_flow", + "targetStep": "place", + } + assert candidate_manifest.is_file() + assert result["candidateManifestSha256"] == sha256_path(candidate_manifest) + 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"] + 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" + ) + ) + assert execution_receipt["candidate_manifest_sha256"] == result["candidateManifestSha256"] + + 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, + 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 == { + "random_seed": 17, + "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 + 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, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + 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.agent.dreamplace_parameter_observer.v3", + "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", + "written_value": 0.6, + "tool": tool, + "status": "effective", + "actual_value": 0.6, + "reason": None, + "observation": { + "target_density": 0.6, + "density_tensor_value": 0.6, + "density_operator_call_count": 3, + "utilization_floor": None, + }, + } + (candidate / "analysis" / "parameter_runtime_report.v2.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, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + 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["status"] == "effective" + 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"] + ) + assert terminal["result"]["parameterApplicationReceiptRef"] == ( + ".agent/candidates/candidate-failed/analysis/parameter_application_receipt.v2.json" + ) + assert terminal["result"]["parameterApplicationReceiptSha256"] == sha256_path( + candidate / "analysis" / "parameter_application_receipt.v2.json" + ) + + +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() + dreamplace = tmp_path / "config" / "dreamplace.json" + dreamplace.parent.mkdir() + dreamplace.write_text('{"random_seed": 3000}', encoding="utf-8") + 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( + flow=SimpleNamespace(data={"steps": [{"name": "Floorplan"}, {"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, + parameter_card_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, config={"dreamplace": dreamplace}), flow, request + ) + + 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 + + +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="Harden", + 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", + context_sha256=CONTEXT_SHA256, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + 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", + 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, + ) + ) + + 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, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + 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="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, + parameter_card_sha256=CONTEXT_SHA256, + seed=17, + ) + ) + + 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="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, + 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() + (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="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 "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="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 "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) + self.events = [] + self.operations = RuntimeOperationManager(self.events.append) + + 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, + 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 + ), + ) def _with_session_mutation_lock(self, workspace_id, operation): assert workspace_id == "workspace-1" @@ -130,8 +1038,17 @@ 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.initialize_config = None self.run_calls = [] + self.observed_random_seeds = [] + + 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( @@ -144,8 +1061,236 @@ 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): + 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) return StateEnum.Success + + +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"] == expected_state + 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/tools.py b/agent/tools.py index 2c36c1add..ee2c71f63 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,7 +1,15 @@ 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 .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 + +ecc_runner.ECCToolsPlot = AgentECCToolsPlot def run_step(workspace: Workspace, step: WorkspaceStep, ecc_module=None) -> bool: @@ -9,6 +17,22 @@ 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) + apply_floorplan_mode(workspace, step.name) log_workspace_step(step, workspace.logger) - return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) + + def run_tool(): + workers = sta_workers(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(): + return eda_module.run_step(workspace=workspace, step=step, ecc_module=ecc_module) + + return run_with_parameter_observation( + workspace, + step, + materialization, + run_tool, + ) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 9c310a17d..c73bbf11c 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -1,8 +1,12 @@ import json +import os +import re import shutil 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 ( RuntimeApiError, @@ -12,21 +16,66 @@ ) 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, 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 +from .data.candidate_materialization import ( + 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, +) from .requests import ( - CandidateBindInputRequest, - CandidateMaterializeRequest, CandidateRerunRequest, + CandidateResumeRequest, WorkspaceExtractFoundationRequest, ) +from .runtime_env import preflight_sizer_runtime + + +def _stable_hash(value) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + 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" + 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): @@ -59,85 +108,165 @@ 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, + def candidate_rerun(self, request: CandidateRerunRequest) -> dict: + _validate_candidate_rerun_request(request) + 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=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._candidate_rerun(session, request, observer), ) + except RuntimeOperationConflict as exc: + raise RuntimeApiError("command_failed", str(exc)) from exc - return self._with_workspace_lock(request.workspace_id, bind) + def candidate_resume(self, request: CandidateResumeRequest) -> dict: + from .candidate_resume import candidate_resume - 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, - ), - ) + return candidate_resume(self, request) - def candidate_rerun(self, request: CandidateRerunRequest) -> dict: - return self._with_workspace_lock( - request.workspace_id, - lambda session: self._candidate_rerun(session, request), - ) - - def _candidate_rerun(self, session, request: CandidateRerunRequest) -> 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) + def _candidate_rerun(self, session, request: CandidateRerunRequest, observer) -> dict: + candidate_workspace = None + candidate_root_ref = None + parent = None + flow = None try: + # 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), + ) + ) + # 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. + 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) steps = _candidate_rerun_steps( flow, request.target_step, request.end_step, request.execution_scope, ) + if not preflight_done: + _preflight_candidate_steps(steps) + 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: - _materialize_candidate_rerun(session.workspace, flow, request) - _prepare_candidate_rerun(session.workspace, flow, steps) - if request.patch: - _reapply_candidate_input(session.workspace, flow, request.target_step) - for step in steps: - _run_candidate_step(flow, step) - return { - "end_step": request.end_step, - "execution_scope": request.execution_scope, - "target_step": request.target_step, - } + _reapply_candidate_input(candidate_workspace, flow, request.target_step) + else: + reapply_candidate_input_binding(candidate_workspace, flow, request.target_step) + run_candidate_steps_isolated(flow, steps, observer=observer) + return _candidate_rerun_result( + candidate_workspace, + request, + candidate_root_ref, + parent, + terminal_state="succeeded", + ) + except Exception as exc: + try: + result = _candidate_rerun_result( + candidate_workspace, + request, + candidate_root_ref, + parent, + terminal_state="failed", + ) + 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._finish_flow( - session, - flow, - should_capture=should_capture, - previous_db=previous_db, + 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, session): - flow = build_agent_flow_for_workspace(session.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 _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 _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) @@ -162,13 +291,34 @@ 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", ())) + range_target = target_step + 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_TARGET_FLOW_STEP[target_step] 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") == range_target), + 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}" @@ -184,7 +334,582 @@ 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}$") + + +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(): + 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.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" + ) + 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") + 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 + ): + 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 ( + 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: + _validate_parent_candidate_root_ref(request.parent_candidate_root_ref) + + +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, + ) + + +_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_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)) + parent = _candidate_parent_binding(workspace_root, parent_candidate_root_ref) + source_root = parent["root"] + candidate_root = _candidate_workspace_root(workspace_root, candidate_id) + candidate_root.parent.mkdir(parents=True, exist_ok=True) + try: + candidate_root.parent.resolve().relative_to(workspace_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( + 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 + 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(workspace_root).as_posix(), + parent, + ) + + +def _workspace_state_sha256(root: Path) -> str: + relative_files = ( + FLOORPLAN_MODE_REF, + "home/flow.json", + "home/parameters.json", + "config/floorplan_ecc.json", + "config/cts_ecc.json", + "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 + 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(): + raise RuntimeApiError("command_failed", "candidate parent workspace is invalid") + 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 _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: + 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: dict, + target_step: str, + end_step: str, + 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(): + 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["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, + } + 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.v2.json"), + ("parameter_application_receipt", "analysis/parameter_application_receipt.v2.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 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) + 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["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": manifest_sha256, + } + try: + write_json_atomic(replay_path, replay) + except OSError as exc: + raise RuntimeApiError( + "command_failed", f"candidate replay receipt write failed: {exc}" + ) from exc + return { + "candidateRootRef": candidate_root_ref, + "candidateManifestRef": f"{candidate_root_ref}/analysis/{_CANDIDATE_WORKSPACE_MANIFEST}", + "candidateManifestSha256": manifest_sha256, + } + + +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: + reapply_materialized_candidate_config(workspace, request.target_step) + parameter_receipt = _candidate_parameter_receipt( + workspace, + request, + candidate_root_ref, + materialization_path, + parent["flow_sha256"], + parent, + ) + except Exception as exc: + 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 + receipt_ref = f"{candidate_root_ref}/analysis/parameter_application_receipt.v2.json" + receipt_sha256 = sha256_path( + Path(workspace.directory) / "analysis" / "parameter_application_receipt.v2.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 + + +def _candidate_parameter_receipt( + workspace, + request, + candidate_root_ref: str, + materialization_path: Path, + parent_flow_sha256: str | None = None, + parent: dict | None = None, +) -> dict: + 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) + tool_name = "ECC-Floorplan" if knob_id.startswith("floorplan.") else "DREAMPlace" + runtime_report_path = ( + Path(workspace.directory) / "analysis" / "parameter_runtime_report.v2.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 + _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) + 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.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) + 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": + written_unit = "dbu" + parent = parent or {} + return build_parameter_application_receipt( + receipt_id=f"parameter-receipt-{request.candidate_id}", + 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["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_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, + ) + + +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.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") + 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( + rf"SITE\s+{site_pattern}\b(?P.*?)END\s+{site_pattern}", + 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 = 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}) + sdc_sha256 = sdc_hashes[0] if len(sdc_hashes) == 1 else _stable_hash({"files": sdc_hashes}) + design_sha256 = _stable_hash( + { + "rtl_sha256": rtl_sha256, + "filelist_sha256": filelist_sha256, + "sdc_sha256": sdc_sha256, + } + ) + 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, + "stage": request.target_step, + "backend": "ecc", + "lattice_version": "ecos.optimization_lattice.v1", + "rtl_sha256": rtl_sha256, + "filelist_sha256": filelist_sha256, + "sdc_sha256": sdc_sha256, + "pdk_sha256": pdk_sha256, + "parent_lineage_sha256": parent_flow_sha256, + "seed": request.seed, + "ecc_revision": ecc_revision, + "site_width_dbu": site_width_dbu, + "unit": unit, + } + return context + + 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, @@ -193,19 +918,69 @@ def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest source_step, request.candidate_id, ) - materialize_candidate_config( - workspace, - request.target_step, - request.patch, - 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) + 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.v2.json", + "parameter_application_receipt.v2.json", + "candidate_materialization.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() + + +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("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 binding is invalid") def _candidate_source_step(flow, target_step: str) -> str: - steps = list(getattr(flow, "workspace_steps", ())) + # 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.name == target_step and index: - return steps[index - 1].name + if step.get("name") == target_step and index: + 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}") @@ -232,6 +1007,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"): @@ -261,9 +1041,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/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/flow.py b/chipcompiler/engine/flow.py index db9707d8a..9bf0ff3e8 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -352,13 +352,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 @@ -398,7 +404,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/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/operations.py b/chipcompiler/runtime/operations.py index c18653dc7..1d1c19e50 100644 --- a/chipcompiler/runtime/operations.py +++ b/chipcompiler/runtime/operations.py @@ -41,6 +41,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 + + class RuntimeOperationIdempotencyConflict(RuntimeError): """A command ID was reused with different immutable input.""" @@ -422,10 +437,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, @@ -451,7 +466,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}, ) self._prune_terminal_locked() self._persist_workspace_locked(operation.workspace_id) 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 de0f9ced6..f5b5ff84a 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: @@ -113,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/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 19cf2d157..86ad7bbab 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -442,7 +442,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, @@ -453,6 +452,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, KeyError): + 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): 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} { 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 0f1dba9b5..3cd348902 100644 --- a/ecc.spec +++ b/ecc.spec @@ -346,6 +346,7 @@ if BUNDLE_MODE == "onedir": upx=False, name="ecc", ) + ecc_exe_path = Path(coll.name) / "ecc" else: exe = EXE( pyz, @@ -360,3 +361,8 @@ 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 9f1e000ca..1189878ce 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,16 @@ 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 chipcompiler.runtime.stdio_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/pyproject.toml b/pyproject.toml index 7e7a65ad7..fca4fc971 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 = "chipcompiler.runtime.stdio_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/engine/test_state_machine_regression.py b/test/engine/test_state_machine_regression.py index 1639ce707..c86e57ec3 100644 --- a/test/engine/test_state_machine_regression.py +++ b/test/engine/test_state_machine_regression.py @@ -475,28 +475,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 - def test_agent_flow_unusable_log_path_does_not_block_execution(tmp_path, monkeypatch): """AgentEngineFlow: unusable step-log path must not block tool execution.""" 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 de400ac1f..218ca7934 100644 --- a/test/packaging/test_cli_entrypoint.py +++ b/test/packaging/test_cli_entrypoint.py @@ -10,7 +10,9 @@ 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"} + assert ( + data["project"]["scripts"]["ecc-agent-rpc"] == "chipcompiler.runtime.stdio_server:main" + ) def test_pyinstaller_spec_collects_jsonrpcserver_data_files(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) @@ -35,6 +37,16 @@ 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 + def test_pyinstaller_spec_collects_doc_guides(self): project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) spec_path = os.path.join(project_root, "ecc.spec") diff --git a/test/packaging/test_run_ecc.py b/test/packaging/test_run_ecc.py new file mode 100644 index 000000000..3ef3d67c0 --- /dev/null +++ b/test/packaging/test_run_ecc.py @@ -0,0 +1,37 @@ +import importlib.util +import os +import sys +from pathlib import Path + +import chipcompiler.runtime.stdio_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( + chipcompiler.runtime.stdio_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 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 7918e4af0..8ef146e99 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 @@ -168,9 +170,68 @@ 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} +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) diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index dcac83457..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, @@ -234,6 +235,21 @@ def fake_load_workspace(directory): return capture, ws +def test_runtime_workspace_defaults_to_rtl2gds_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 *, skip: [("rtl2gds", "ecc", "Unstart")], + ) + + flow = build_flow_for_workspace(workspace) + + assert flow.added_steps == [("rtl2gds", "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() @@ -1765,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"] diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 0fb6bacf5..ee0f6ba50 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -349,6 +349,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 --- diff --git a/test/tools/ecc_dreamplace/test_module.py b/test/tools/ecc_dreamplace/test_module.py index 9d72d6e43..1bab85510 100644 --- a/test/tools/ecc_dreamplace/test_module.py +++ b/test/tools/ecc_dreamplace/test_module.py @@ -96,6 +96,7 @@ def test_build_params_preserves_routability_config_and_forces_timing_off(tmp_pat json_write( config_path, { + "random_seed": 17, "macro_only": 1, "routability_opt_flag": 1, "get_congestion_map": 1, @@ -129,6 +130,7 @@ def test_build_params_preserves_routability_config_and_forces_timing_off(tmp_pat params = module._build_params(FakeParams, mode=DreamplaceRunMode.PLACEMENT) assert params.routability_opt_flag == 1 + assert params.random_seed == 17 assert params.get_congestion_map == 1 assert params.macro_only == 0 assert params.with_sta is False