From 754b9f0c5ad86ac06dadfb5a97cefea28d53ba8d Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Tue, 8 Sep 2026 23:38:43 +0800
Subject: [PATCH 01/25] fix(cli): align parameter filters with flow steps
---
chipcompiler/cli/command_handlers/param.py | 30 +++++++++++++-
.../cli/command_handlers/workspace_params.py | 15 ++++---
test/cli/params/test_commands.py | 39 +++++++++++++++++++
test/cli/params/test_workspace_commands.py | 31 +++++++++++++++
4 files changed, 108 insertions(+), 7 deletions(-)
diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py
index 8a56b08f..0fe62d6f 100644
--- a/chipcompiler/cli/command_handlers/param.py
+++ b/chipcompiler/cli/command_handlers/param.py
@@ -11,6 +11,7 @@
validate_pdk_target,
validate_value,
)
+from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step
from chipcompiler.utility.file import write_text_atomic
@@ -29,6 +30,20 @@ def _manifest_mode_error(ctx: CommandContext) -> CommandResult | None:
return None
+def _parameter_flow_error(schema, ctx: CommandContext) -> CommandResult | None:
+ preset = getattr(ctx.config, "flow_preset", "")
+ builder = get_flow_builders().get(preset)
+ if builder is None or schema.applies == "all" or schema.pdk_target is not None:
+ return None
+ flow_steps = {normalize_flow_step(step).casefold() for step, _tool, _state in builder()}
+ if normalize_flow_step(schema.applies).casefold() in flow_steps:
+ return None
+ return CommandResult.err(
+ [error_record("parameter_not_in_flow", param=schema.param, preset=preset)],
+ exit_code=1,
+ )
+
+
def param_list(args, ctx: CommandContext) -> CommandResult:
if getattr(args, "workspace", None) is not None:
from chipcompiler.cli.command_handlers import workspace_params
@@ -45,12 +60,17 @@ def param_list(args, ctx: CommandContext) -> CommandResult:
resolved, _ = resolve_parameters(toml_overrides=toml_overrides)
project = ctx.project
- selected_step = (getattr(args, "step", None) or "").casefold()
+ selected_step = normalize_flow_step(getattr(args, "step", None) or "").casefold()
show_all = bool(getattr(args, "all", False))
records = []
for rp in resolved:
s = rp.schema
- if selected_step and selected_step not in {s.group.casefold(), s.applies.casefold()}:
+ schema_steps = {
+ normalize_flow_step(s.group).casefold(),
+ normalize_flow_step(s.applies).casefold(),
+ }
+ global_at_first_step = s.applies == "all" and selected_step == "synthesis"
+ if selected_step and selected_step not in schema_steps and not global_at_first_step:
continue
if not selected_step and not show_all and s.has_direct_target and not rp.is_explicit:
continue
@@ -160,6 +180,9 @@ def param_set(args, ctx: CommandContext) -> CommandResult:
],
exit_code=1,
)
+ flow_error = _parameter_flow_error(schema, ctx)
+ if flow_error is not None:
+ return flow_error
try:
value = parse_value(raw_value, schema)
@@ -248,6 +271,9 @@ def param_unset(args, ctx: CommandContext) -> CommandResult:
],
exit_code=1,
)
+ flow_error = _parameter_flow_error(schema, ctx)
+ if flow_error is not None:
+ return flow_error
config_path = _find_config_path(ctx.project_dir)
if config_path is None:
diff --git a/chipcompiler/cli/command_handlers/workspace_params.py b/chipcompiler/cli/command_handlers/workspace_params.py
index 8c576c77..673de650 100644
--- a/chipcompiler/cli/command_handlers/workspace_params.py
+++ b/chipcompiler/cli/command_handlers/workspace_params.py
@@ -12,6 +12,7 @@
workspace_param_step,
workspace_param_value,
)
+from chipcompiler.rtl2gds import normalize_flow_step
def param_set(args, ctx: CommandContext) -> CommandResult:
@@ -62,15 +63,19 @@ def param_list(args, ctx: CommandContext) -> CommandResult:
if workspace_error is not None:
return workspace_error
overrides = {record["key"] for record in workspace_param_diff(workspace)}
- selected_step = (args.step or "").casefold()
+ selected_step = normalize_flow_step(args.step or "").casefold()
+ flow_steps = workspace.flow.data.get("steps", [])
+ first_step = normalize_flow_step(flow_steps[0]["name"]).casefold() if flow_steps else ""
records = []
for schema in list_schemas():
if schema.pdk_target is not None or (not args.all and schema.param not in overrides):
continue
- if selected_step and selected_step not in {
- schema.group.casefold(),
- schema.applies.casefold(),
- }:
+ schema_steps = {
+ normalize_flow_step(schema.group).casefold(),
+ normalize_flow_step(schema.applies).casefold(),
+ }
+ global_at_first_step = schema.applies == "all" and selected_step == first_step
+ if selected_step and selected_step not in schema_steps and not global_at_first_step:
continue
try:
value = workspace_param_value(workspace, schema)
diff --git a/test/cli/params/test_commands.py b/test/cli/params/test_commands.py
index abeeac71..015e7836 100644
--- a/test/cli/params/test_commands.py
+++ b/test/cli/params/test_commands.py
@@ -26,6 +26,20 @@ def test_param_list_step_includes_config_schemas(self, tmp_path, capsys, create_
assert rc == 0
assert "cts.skew_bound" in capsys.readouterr().out
+ def test_param_list_synthesis_includes_global_parameters(
+ self, tmp_path, capsys, create_cli_project, plain_records
+ ):
+ project_dir = create_cli_project()
+ rc = cli_main.run(
+ ["param", "list", "--step", "synthesis", "--project", project_dir, "--plain"]
+ )
+
+ assert rc == 0
+ assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [
+ "design.frequency_mhz",
+ "flow.run_analysis",
+ ]
+
class TestParamShow:
def test_param_show_known_key(self, tmp_path, capsys, create_cli_project):
@@ -90,6 +104,31 @@ def test_param_set_rejects_invalid_value(self, tmp_path, capsys, create_cli_proj
rc = cli_main.run(["param", "set", "place.target_density", "1.5", "--project", project_dir])
assert rc == 1
+ def test_param_set_rejects_parameter_outside_project_flow(
+ self, tmp_path, capsys, create_cli_project, plain_records
+ ):
+ project_dir = create_cli_project()
+ config_path = tmp_path / "gcd" / "ecc.toml"
+ config_path.write_text(
+ config_path.read_text().replace('preset = "rtl2gds"', 'preset = "syn_sta"')
+ )
+
+ rc = cli_main.run(
+ [
+ "param",
+ "set",
+ "place.target_density",
+ "0.65",
+ "--project",
+ project_dir,
+ "--plain",
+ ]
+ )
+
+ assert rc == 1
+ assert plain_records(capsys.readouterr().out)[0]["error"] == "parameter_not_in_flow"
+ assert "target_density" not in config_path.read_text()
+
def test_param_set_accepts_negative_value(
self, tmp_path, capsys, create_cli_project, plain_records
):
diff --git a/test/cli/params/test_workspace_commands.py b/test/cli/params/test_workspace_commands.py
index 684988f2..cdfa0aa9 100644
--- a/test/cli/params/test_workspace_commands.py
+++ b/test/cli/params/test_workspace_commands.py
@@ -200,3 +200,34 @@ def test_workspace_param_list_honors_step_filter(
assert plain_records(capsys.readouterr().out) == [
{"param": "list", "status": "clean", "workspace": "baseline"}
]
+
+
+def test_workspace_param_list_matches_first_step_configuration(
+ capsys, create_cli_project, monkeypatch, plain_records
+):
+ project_dir = create_cli_project()
+ workspace_dir = Path(project_dir) / "baseline"
+ _write_manifest(project_dir)
+ workspace = _workspace(workspace_dir)
+ monkeypatch.setattr("chipcompiler.data.load_workspace", lambda _path: workspace)
+
+ rc = cli_main.run(
+ [
+ "param",
+ "list",
+ "--workspace",
+ "baseline",
+ "--step",
+ "synthesis",
+ "--all",
+ "--project",
+ project_dir,
+ "--plain",
+ ]
+ )
+
+ assert rc == 0
+ assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [
+ "design.frequency_mhz",
+ "flow.run_analysis",
+ ]
From aa270dfaab8e1540a6a9ac705abb6fc3ab58b4ac Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 00:16:36 +0800
Subject: [PATCH 02/25] feat(workspace): share project and workspace contracts
---
chipcompiler/cli/project/manifest_write.py | 64 ++-
chipcompiler/engine/__init__.py | 6 +
chipcompiler/engine/pdk_binding.py | 64 +++
chipcompiler/engine/workspace_lifecycle.py | 144 +++++++
chipcompiler/engine/workspace_spec.py | 431 +++++++++++++++++++
chipcompiler/project/__init__.py | 15 +
chipcompiler/project/api.py | 279 ++++++++++++
test/engine/test_workspace_spec.py | 151 +++++++
test/fixtures/workspace_spec/input/gcd.v | 3 +
test/fixtures/workspace_spec/input/sources.f | 1 +
test/fixtures/workspace_spec/invalid.json | 67 +++
test/fixtures/workspace_spec/pdk/.keep | 1 +
test/fixtures/workspace_spec/valid.json | 68 +++
test/project/__init__.py | 0
test/project/test_manifest.py | 53 +++
15 files changed, 1327 insertions(+), 20 deletions(-)
create mode 100644 chipcompiler/engine/pdk_binding.py
create mode 100644 chipcompiler/engine/workspace_lifecycle.py
create mode 100644 chipcompiler/engine/workspace_spec.py
create mode 100644 chipcompiler/project/__init__.py
create mode 100644 chipcompiler/project/api.py
create mode 100644 test/engine/test_workspace_spec.py
create mode 100644 test/fixtures/workspace_spec/input/gcd.v
create mode 100644 test/fixtures/workspace_spec/input/sources.f
create mode 100644 test/fixtures/workspace_spec/invalid.json
create mode 100644 test/fixtures/workspace_spec/pdk/.keep
create mode 100644 test/fixtures/workspace_spec/valid.json
create mode 100644 test/project/__init__.py
create mode 100644 test/project/test_manifest.py
diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py
index c1d422db..bc90e154 100644
--- a/chipcompiler/cli/project/manifest_write.py
+++ b/chipcompiler/cli/project/manifest_write.py
@@ -96,16 +96,51 @@ def build_manifest_document(
) -> dict:
"""Assemble a schema-v1 manifest for a virgin project's first run."""
now = _now_iso()
- name = os.path.basename(os.path.normpath(project_dir)) or "project"
- document: dict[str, Any] = {
+ document = build_project_document(
+ project_dir,
+ design_name=design_name,
+ base_design=base_design,
+ now=now,
+ )
+ document["workspaces"] = [
+ manifest_workspace_entry(
+ workspace_id,
+ name=design_name,
+ workspace_path=workspace_path,
+ start_step=start_step,
+ end_step=end_step,
+ status=status,
+ now=now,
+ )
+ ]
+ document["qor_baseline"] = {
+ "workspace_id": workspace_id,
+ "reason": "Default project QoR baseline",
+ }
+ return document
+
+
+def build_project_document(
+ project_dir: str,
+ *,
+ design_name: str,
+ base_design: dict,
+ name: str | None = None,
+ now: str | None = None,
+ mpc: dict | None = None,
+) -> dict[str, Any]:
+ """Assemble a schema-v1 Project Manifest without a Workspace."""
+ timestamp = now or _now_iso()
+ project_name = name or os.path.basename(os.path.normpath(project_dir)) or "project"
+ return {
"schema_version": 1,
- "project_id": f"proj_{_slugify(name)}",
- "name": name,
+ "project_id": f"proj_{_slugify(project_name)}",
+ "name": project_name,
"design_name": design_name,
"description": "",
"root_path": project_dir,
- "created_at": now,
- "updated_at": now,
+ "created_at": timestamp,
+ "updated_at": timestamp,
"base_design": {
**{key: value for key, value in base_design.items() if key != "parameters" and value},
"parameters": _record(base_design.get("parameters")),
@@ -114,22 +149,11 @@ def build_manifest_document(
],
},
"objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)),
- "workspaces": [
- manifest_workspace_entry(
- workspace_id,
- name=design_name,
- workspace_path=workspace_path,
- start_step=start_step,
- end_step=end_step,
- status=status,
- now=now,
- )
- ],
- "mpc": None,
+ "workspaces": [],
+ "mpc": deepcopy(mpc),
"best_workspace": None,
- "qor_baseline": {"workspace_id": workspace_id, "reason": "Default project QoR baseline"},
+ "qor_baseline": None,
}
- return document
def write_manifest_if_absent(project_dir: str, document: dict) -> bool:
diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py
index f75ec0ef..459a008e 100644
--- a/chipcompiler/engine/__init__.py
+++ b/chipcompiler/engine/__init__.py
@@ -2,6 +2,8 @@
from .flow import EngineFlow
from .rerun import StepRunResult
from .signoff import SignoffPackageCollector, SignoffPackageOptions
+from .workspace_lifecycle import WorkspaceLifecycleError, create_workspace_from_spec
+from .workspace_spec import describe_workspace_spec, validate_workspace_spec
__all__ = [
"EngineDB",
@@ -9,4 +11,8 @@
"StepRunResult",
"SignoffPackageCollector",
"SignoffPackageOptions",
+ "WorkspaceLifecycleError",
+ "create_workspace_from_spec",
+ "describe_workspace_spec",
+ "validate_workspace_spec",
]
diff --git a/chipcompiler/engine/pdk_binding.py b/chipcompiler/engine/pdk_binding.py
new file mode 100644
index 00000000..f26134ac
--- /dev/null
+++ b/chipcompiler/engine/pdk_binding.py
@@ -0,0 +1,64 @@
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+
+def pdk_binding_content_hash(
+ spec: dict[str, Any],
+ bindings: dict[str, Any],
+) -> str:
+ digest = hashlib.sha256()
+ digest.update(str(spec.get("familyId", "")).encode())
+ digest.update(str(spec.get("version") or bindings.get("version") or "").encode())
+ digest.update(json.dumps(spec.get("overrides", {}), sort_keys=True).encode())
+ for path in _selected_paths(spec, bindings):
+ digest.update(path.read_bytes())
+ return digest.hexdigest()
+
+
+def _selected_paths(spec: dict[str, Any], bindings: dict[str, Any]) -> list[Path]:
+ if spec.get("mode") == "manual":
+ files = bindings.get("files")
+ if not isinstance(files, dict):
+ return []
+ return [
+ Path(files[ref["fileId"]])
+ for ref in spec.get("files", [])
+ if isinstance(ref, dict)
+ and isinstance(ref.get("fileId"), str)
+ and ref["fileId"] in files
+ and Path(files[ref["fileId"]]).is_file()
+ ]
+
+ try:
+ from chipcompiler.data import get_pdk
+
+ pdk = get_pdk(
+ spec.get("familyId", ""),
+ pdk_root=bindings.get("root", ""),
+ overrides=pdk_overrides_from_binding(spec, bindings),
+ )
+ except (OSError, ValueError):
+ return []
+ candidates = [pdk.tech, *pdk.lefs, *pdk.libs, pdk.mapping_file]
+ return [Path(path) for path in candidates if path is not None and Path(path).is_file()]
+
+
+def pdk_overrides_from_binding(spec: dict[str, Any], bindings: dict[str, Any]) -> dict[str, Any]:
+ overrides = dict(spec.get("overrides", {}))
+ files = bindings.get("files", {})
+ by_role: dict[str, list[str]] = {}
+ for ref in spec.get("files", []):
+ path = files.get(ref.get("fileId")) if isinstance(files, dict) else None
+ if isinstance(path, str):
+ by_role.setdefault(ref["role"], []).append(path)
+ if by_role.get("tech"):
+ overrides["tech"] = by_role["tech"][0]
+ if by_role.get("lef"):
+ overrides["lefs"] = by_role["lef"]
+ if by_role.get("liberty"):
+ overrides["libs"] = by_role["liberty"]
+ if by_role.get("mapping"):
+ overrides["mapping_file"] = by_role["mapping"][0]
+ return overrides
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
new file mode 100644
index 00000000..ea561c5f
--- /dev/null
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -0,0 +1,144 @@
+import shutil
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from chipcompiler.cli.project.params import build_backend_overrides, resolve_parameters
+from chipcompiler.data import PDK, create_workspace
+from chipcompiler.engine.workspace_spec import validate_workspace_spec
+from chipcompiler.rtl2gds import get_flow_builders
+
+
+class WorkspaceLifecycleError(RuntimeError):
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
+ super().__init__(message)
+ self.code = code
+ self.details = details or {}
+
+
+def create_workspace_from_spec(
+ target_directory: str | Path,
+ spec: object,
+ bindings: object,
+ command_id: str = "",
+):
+ del command_id
+ target = Path(target_directory).expanduser().resolve()
+ if target.exists():
+ raise WorkspaceLifecycleError("workspace_exists", f"Workspace already exists: {target}")
+ validation = validate_workspace_spec(spec, bindings)
+ issues = validation["issues"]
+ if issues:
+ raise WorkspaceLifecycleError(
+ "workspace_spec_invalid",
+ "Workspace Spec validation failed",
+ {"issues": issues},
+ )
+ resolved = validation["resolvedWorkspaceSpec"]
+ binding_map = _string_keyed_dict(bindings)
+ input_bindings = _string_keyed_dict(binding_map.get("inputs"))
+ input_paths = {
+ item["role"]: str(input_bindings[item["inputId"]]) for item in resolved["inputs"]
+ }
+ rtl_paths = [
+ str(input_bindings[item["inputId"]]) for item in resolved["inputs"] if item["role"] == "rtl"
+ ]
+ parameters, errors = resolve_parameters(toml_overrides=resolved["parameters"])
+ if errors:
+ raise WorkspaceLifecycleError(
+ "workspace_spec_invalid",
+ "Workspace parameters are invalid",
+ {"issues": errors},
+ )
+ backend_parameters = build_backend_overrides(parameters)
+ backend_parameters.update(
+ {
+ "design": resolved["design"]["name"],
+ "top_module": resolved["design"]["topModule"],
+ "clock": resolved["design"].get("clockPort", ""),
+ }
+ )
+ flow_steps = get_flow_builders()[resolved["flow"]["flowId"]]()
+ flow_config = {
+ "start_step": str(getattr(flow_steps[0][0], "value", flow_steps[0][0])),
+ "end_step": str(getattr(flow_steps[-1][0], "value", flow_steps[-1][0])),
+ }
+ pdk, pdk_root, pdk_overrides = _bound_pdk(
+ resolved["pdk"], _string_keyed_dict(binding_map["pdk"])
+ )
+
+ generated_filelist: str | None = None
+ if len(rtl_paths) > 1:
+ with tempfile.NamedTemporaryFile(
+ "w", encoding="utf-8", suffix=".f", delete=False
+ ) as handle:
+ handle.write("\n".join(rtl_paths) + "\n")
+ generated_filelist = handle.name
+
+ try:
+ workspace = create_workspace(
+ directory=target,
+ origin_def=input_paths.get("def", ""),
+ origin_verilog=(
+ input_paths.get("netlist", "") or (rtl_paths[0] if len(rtl_paths) == 1 else "")
+ ),
+ input_filelist=input_paths.get("filelist", "") or generated_filelist or "",
+ golden_verilog=input_paths.get("goldenNetlist", ""),
+ sdc=input_paths.get("sdc", ""),
+ spef=input_paths.get("spef", ""),
+ pdk=pdk,
+ pdk_root=pdk_root,
+ pdk_overrides=pdk_overrides,
+ parameters=backend_parameters,
+ flow_config=flow_config,
+ )
+ if workspace is None:
+ raise WorkspaceLifecycleError(
+ "workspace_create_failed", f"Workspace creation failed: {target}"
+ )
+ return workspace
+ except Exception:
+ shutil.rmtree(target, ignore_errors=True)
+ raise
+ finally:
+ if generated_filelist is not None:
+ Path(generated_filelist).unlink(missing_ok=True)
+
+
+def _bound_pdk(spec: dict, binding: dict) -> tuple[PDK | str, str, dict | None]:
+ root = Path(str(binding["root"]))
+ overrides = spec.get("overrides", {})
+ if spec["mode"] != "manual":
+ return (
+ spec["familyId"],
+ str(root),
+ {
+ **({"dont_use": overrides["dont_use"]} if "dont_use" in overrides else {}),
+ **({"abc_load": overrides["abc_load"]} if "abc_load" in overrides else {}),
+ },
+ )
+ files = binding.get("files", {})
+ by_role: dict[str, list[Path]] = {}
+ for item in spec.get("files", []):
+ by_role.setdefault(item["role"], []).append(Path(str(files[item["fileId"]])))
+ return (
+ PDK(
+ name=spec["familyId"],
+ version=spec.get("version", ""),
+ root=root,
+ tech=by_role["tech"][0],
+ lefs=by_role.get("lef", []),
+ libs=by_role.get("liberty", []),
+ mapping_file=(by_role.get("mapping") or [None])[0],
+ dont_use=overrides.get("dont_use", []),
+ abc_load=float(overrides.get("abc_load", 0.015)),
+ ),
+ "",
+ None,
+ )
+
+
+def _string_keyed_dict(value: object) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ return {}
+ return {str(key): item for key, item in value.items()}
diff --git a/chipcompiler/engine/workspace_spec.py b/chipcompiler/engine/workspace_spec.py
new file mode 100644
index 00000000..9facf0fd
--- /dev/null
+++ b/chipcompiler/engine/workspace_spec.py
@@ -0,0 +1,431 @@
+from copy import deepcopy
+from pathlib import Path
+from typing import Any
+
+from chipcompiler.cli.project.params import list_schemas, resolve_parameters
+from chipcompiler.engine.pdk_binding import pdk_binding_content_hash
+from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step
+from chipcompiler.utility.filelist import validate_filelist
+
+SCHEMA_VERSION = 1
+_INPUT_ROLES = frozenset({"rtl", "filelist", "netlist", "goldenNetlist", "def", "sdc", "spef"})
+_PDK_FILE_ROLES = frozenset({"tech", "lef", "liberty", "mapping"})
+_SPEC_FIELDS = frozenset(
+ {"schemaVersion", "design", "inputMode", "inputs", "pdk", "flow", "mpc", "parameters"}
+)
+
+
+def describe_workspace_spec() -> dict[str, Any]:
+ flows = []
+ for flow_id, builder in sorted(get_flow_builders().items()):
+ steps = [_enum_value(step) for step, _tool, _state in builder()]
+ flows.append({"flowId": flow_id, "stepIds": steps})
+ return {
+ "schemaVersion": SCHEMA_VERSION,
+ "parameterCatalog": [
+ {
+ "id": schema.param,
+ "type": schema.type,
+ "default": deepcopy(schema.default),
+ "appliesTo": schema.applies,
+ "backendMapping": deepcopy(schema.maps_to),
+ **({"range": list(schema.range)} if schema.range else {}),
+ **({"choices": list(schema.choices)} if schema.choices else {}),
+ **({"unit": schema.unit} if schema.unit else {}),
+ }
+ for schema in list_schemas()
+ ],
+ "flowDefinitions": flows,
+ "inputRoleRules": {
+ "roles": sorted(_INPUT_ROLES),
+ "rtl": {"rtl": "oneOrMore", "filelist": "exactlyOneAlternative"},
+ "postSynthesis": {"netlist": "exactlyOne"},
+ "manualPdk": {
+ "tech": "exactlyOne",
+ "lef": "oneOrMore",
+ "liberty": "oneOrMore",
+ "mapping": "zeroOrOne",
+ },
+ },
+ }
+
+
+def validate_workspace_spec(spec: object, bindings: object) -> dict[str, Any]:
+ issues: list[dict[str, Any]] = []
+ if not isinstance(spec, dict):
+ return {"issues": [_issue("invalid_type", "", expected="object")]}
+ spec = {str(key): value for key, value in spec.items()}
+ if not isinstance(bindings, dict):
+ bindings = {}
+ issues.append(_issue("invalid_type", "/bindings", expected="object"))
+ else:
+ bindings = {str(key): value for key, value in bindings.items()}
+
+ _unknown_fields(spec, _SPEC_FIELDS, "", issues)
+ if spec.get("schemaVersion") != SCHEMA_VERSION or isinstance(spec.get("schemaVersion"), bool):
+ issues.append(
+ _issue(
+ "unsupported_schema_version",
+ "/schemaVersion",
+ supported=SCHEMA_VERSION,
+ )
+ )
+
+ design = _mapping(spec.get("design"), "/design", issues)
+ _unknown_fields(design, {"name", "topModule", "clockPort"}, "/design", issues)
+ _required_string(design, "name", "/design/name", issues)
+ _required_string(design, "topModule", "/design/topModule", issues)
+ if "clockPort" in design and not _nonempty_string(design["clockPort"]):
+ issues.append(_issue("invalid_value", "/design/clockPort"))
+
+ input_mode = spec.get("inputMode")
+ if input_mode not in {"rtl", "postSynthesis"}:
+ issues.append(_issue("invalid_input_mode", "/inputMode"))
+ inputs = _refs(spec.get("inputs"), "inputId", _INPUT_ROLES, "/inputs", issues)
+ input_bindings = _input_bindings(bindings.get("inputs"), issues)
+ _validate_binding_set(inputs, input_bindings, issues)
+ _validate_input_roles(input_mode, inputs, issues)
+ _validate_input_files(inputs, input_bindings, issues)
+
+ pdk = _mapping(spec.get("pdk"), "/pdk", issues)
+ _unknown_fields(pdk, {"familyId", "version", "mode", "files", "overrides"}, "/pdk", issues)
+ _required_string(pdk, "familyId", "/pdk/familyId", issues)
+ mode = pdk.get("mode")
+ if mode not in {"default", "manual"}:
+ issues.append(_issue("invalid_pdk_mode", "/pdk/mode"))
+ overrides = pdk.get("overrides", {})
+ if not isinstance(overrides, dict):
+ issues.append(_issue("invalid_type", "/pdk/overrides", expected="object"))
+ else:
+ _unknown_fields(overrides, {"dont_use", "abc_load"}, "/pdk/overrides", issues)
+ if "dont_use" in overrides and not (
+ isinstance(overrides["dont_use"], list)
+ and all(isinstance(value, str) for value in overrides["dont_use"])
+ ):
+ issues.append(_issue("invalid_type", "/pdk/overrides/dont_use", expected="array"))
+ if "abc_load" in overrides and (
+ isinstance(overrides["abc_load"], bool)
+ or not isinstance(overrides["abc_load"], (int, float))
+ ):
+ issues.append(_issue("invalid_type", "/pdk/overrides/abc_load", expected="number"))
+ pdk_bindings = _mapping(bindings.get("pdk"), "/bindings/pdk", issues)
+ root = pdk_bindings.get("root")
+ if not isinstance(root, str) or not root.strip() or not Path(root).is_dir():
+ issues.append(_issue("pdk_binding_missing", "/bindings/pdk/root"))
+ _validate_pdk_files(pdk, pdk_bindings, issues)
+ requested_version = pdk.get("version")
+ bound_version = pdk_bindings.get("version")
+ if requested_version and bound_version and requested_version != bound_version:
+ issues.append(
+ _issue(
+ "pdk_binding_mismatch",
+ "/bindings/pdk/version",
+ expected=requested_version,
+ actual=bound_version,
+ )
+ )
+
+ flow = _mapping(spec.get("flow"), "/flow", issues)
+ _unknown_fields(flow, {"flowId", "fromStepId", "throughStepId"}, "/flow", issues)
+ flow_steps = _validate_flow(flow, issues)
+
+ parameters = spec.get("parameters", {})
+ if not isinstance(parameters, dict):
+ issues.append(_issue("invalid_type", "/parameters", expected="object"))
+ parameters = {}
+ else:
+ parameters = {str(key): value for key, value in parameters.items()}
+ known_parameters = {schema.param for schema in list_schemas()}
+ for parameter_id in parameters:
+ if parameter_id not in known_parameters:
+ issues.append(_issue("unknown_parameter", f"/parameters/{_pointer(parameter_id)}"))
+ resolved_parameters, parameter_errors = resolve_parameters(
+ toml_overrides={key: value for key, value in parameters.items() if key in known_parameters}
+ )
+ for error in parameter_errors:
+ parameter_id = next((key for key in parameters if key in error), "")
+ issues.append(
+ _issue(
+ "invalid_parameter",
+ f"/parameters/{_pointer(parameter_id)}" if parameter_id else "/parameters",
+ reason=error,
+ )
+ )
+ _validate_parameter_applicability(parameters, flow_steps, issues)
+
+ mpc = spec.get("mpc")
+ if mpc is not None:
+ mpc = _mapping(mpc, "/mpc", issues)
+ _unknown_fields(mpc, {"resourceId", "version", "designId"}, "/mpc", issues)
+ for key in ("resourceId", "version", "designId"):
+ _required_string(mpc, key, f"/mpc/{key}", issues)
+ mpc_binding = _mapping(bindings.get("mpc"), "/bindings/mpc", issues)
+ if not isinstance(mpc_binding.get("template"), dict):
+ issues.append(_issue("mpc_binding_missing", "/bindings/mpc/template"))
+
+ if any(issue["severity"] == "error" for issue in issues):
+ return {"issues": issues}
+
+ resolved = deepcopy(spec)
+ resolved["parameters"] = _effective_parameter_values(resolved_parameters, flow_steps)
+ resolved["pdk"] = {
+ **deepcopy(pdk),
+ "version": requested_version or bound_version or "unversioned",
+ "contentHash": pdk_binding_content_hash(pdk, pdk_bindings),
+ }
+ return {"resolvedWorkspaceSpec": resolved, "issues": issues}
+
+
+def _validate_input_roles(
+ input_mode: object,
+ inputs: list[dict[str, str]],
+ issues: list[dict[str, Any]],
+) -> None:
+ roles = [item["role"] for item in inputs]
+ if input_mode == "rtl":
+ if ("rtl" in roles) == ("filelist" in roles):
+ issues.append(_issue("conflicting_input_roles", "/inputs"))
+ if roles.count("filelist") > 1:
+ issues.append(_issue("filelist_cardinality", "/inputs"))
+ if "netlist" in roles:
+ issues.append(_issue("input_role_not_allowed", "/inputs", mode="rtl"))
+ elif input_mode == "postSynthesis":
+ if roles.count("netlist") != 1:
+ issues.append(_issue("netlist_cardinality", "/inputs"))
+ if any(role in {"rtl", "filelist"} for role in roles):
+ issues.append(_issue("input_role_not_allowed", "/inputs", mode="postSynthesis"))
+ for role in ("netlist", "goldenNetlist", "def", "sdc", "spef"):
+ if roles.count(role) > 1:
+ issues.append(_issue(f"{role}_cardinality", "/inputs"))
+
+
+def _validate_input_files(
+ refs: list[dict[str, str]],
+ bindings: dict[str, str],
+ issues: list[dict[str, Any]],
+) -> None:
+ for ref in refs:
+ input_id = ref["inputId"]
+ path = bindings.get(input_id)
+ if not path:
+ continue
+ source = Path(path)
+ pointer = f"/bindings/inputs/{_pointer(input_id)}"
+ if not source.is_file():
+ issues.append(_issue("input_unreadable", pointer))
+ continue
+ if ref["role"] != "filelist":
+ continue
+ try:
+ _existing, missing = validate_filelist(str(source))
+ except (OSError, ValueError) as exc:
+ issues.append(_issue("filelist_invalid", pointer, reason=str(exc)))
+ continue
+ if missing:
+ issues.append(_issue("filelist_source_missing", pointer, missing=missing))
+
+
+def _validate_binding_set(
+ refs: list[dict[str, str]],
+ bindings: dict[str, str],
+ issues: list[dict[str, Any]],
+) -> None:
+ ref_ids = {item["inputId"] for item in refs}
+ for input_id in sorted(ref_ids - bindings.keys()):
+ issues.append(_issue("input_binding_missing", f"/bindings/inputs/{_pointer(input_id)}"))
+ for input_id in sorted(bindings.keys() - ref_ids):
+ issues.append(_issue("unknown_input_binding", f"/bindings/inputs/{_pointer(input_id)}"))
+
+
+def _validate_pdk_files(
+ pdk: dict[str, Any],
+ bindings: dict[str, Any],
+ issues: list[dict[str, Any]],
+) -> None:
+ mode = pdk.get("mode")
+ refs = _refs(pdk.get("files", []), "fileId", _PDK_FILE_ROLES, "/pdk/files", issues)
+ roles = [item["role"] for item in refs]
+ rules = (
+ (
+ ("tech", roles.count("tech") == 1),
+ ("lef", roles.count("lef") >= 1),
+ ("liberty", roles.count("liberty") >= 1),
+ ("mapping", roles.count("mapping") <= 1),
+ )
+ if mode == "manual"
+ else (
+ ("tech", roles.count("tech") <= 1),
+ ("mapping", roles.count("mapping") <= 1),
+ )
+ )
+ for role, valid in rules:
+ if not valid:
+ issues.append(_issue(f"pdk_{role}_cardinality", "/pdk/files"))
+ raw_file_bindings = bindings.get("files", {})
+ file_bindings = raw_file_bindings if isinstance(raw_file_bindings, dict) else {}
+ ref_ids = {item["fileId"] for item in refs}
+ for file_id in sorted(ref_ids - file_bindings.keys()):
+ issues.append(
+ _issue("pdk_file_binding_missing", f"/bindings/pdk/files/{_pointer(file_id)}")
+ )
+ for file_id in sorted(file_bindings.keys() - ref_ids):
+ issues.append(
+ _issue("unknown_pdk_file_binding", f"/bindings/pdk/files/{_pointer(file_id)}")
+ )
+ for file_id, path in file_bindings.items():
+ if file_id in ref_ids and (not isinstance(path, str) or not Path(path).is_file()):
+ issues.append(_issue("pdk_file_unreadable", f"/bindings/pdk/files/{_pointer(file_id)}"))
+
+
+def _validate_flow(flow: dict[str, Any], issues: list[dict[str, Any]]) -> set[str]:
+ flow_id = flow.get("flowId")
+ builder = get_flow_builders().get(flow_id) if isinstance(flow_id, str) else None
+ if builder is None:
+ issues.append(_issue("unknown_flow", "/flow/flowId"))
+ return set()
+ steps = [_enum_value(step) for step, _tool, _state in builder()]
+ start = flow.get("fromStepId")
+ end = flow.get("throughStepId")
+ if (start is None) != (end is None):
+ issues.append(_issue("flow_boundaries_incomplete", "/flow"))
+ return set(steps)
+ if start is None:
+ return set(steps)
+ if start not in steps:
+ issues.append(_issue("unknown_flow_boundary", "/flow/fromStepId"))
+ if end not in steps:
+ issues.append(_issue("unknown_flow_boundary", "/flow/throughStepId"))
+ if start in steps and end in steps:
+ start_index, end_index = steps.index(start), steps.index(end)
+ if start_index > end_index:
+ issues.append(_issue("flow_boundary_reversed", "/flow"))
+ else:
+ return set(steps[start_index : end_index + 1])
+ return set()
+
+
+def _validate_parameter_applicability(
+ explicit: dict[str, Any],
+ flow_steps: set[str],
+ issues: list[dict[str, Any]],
+) -> None:
+ normalized_steps = {step.lower() for step in flow_steps}
+ aliases = {
+ "synthesis": "synth",
+ "floorplan": "floor",
+ "fixfanout": "fanout",
+ "placement": "place",
+ "routing": "route",
+ }
+ normalized_steps |= {aliases.get(step, step) for step in normalized_steps}
+ for schema in list_schemas():
+ if schema.param not in explicit:
+ continue
+ if schema.applies == "all":
+ continue
+ applies = aliases.get(schema.applies.lower(), schema.applies.lower())
+ if flow_steps and applies not in normalized_steps:
+ issues.append(
+ _issue(
+ "inapplicable_parameter",
+ f"/parameters/{_pointer(schema.param)}",
+ appliesTo=schema.applies,
+ )
+ )
+
+
+def _effective_parameter_values(resolved, steps: set[str]) -> dict[str, object]:
+ available = {normalize_flow_step(step).casefold() for step in steps}
+ return {
+ parameter.param: deepcopy(parameter.value)
+ for parameter in resolved
+ if parameter.schema.pdk_target is None
+ and (
+ parameter.schema.applies == "all"
+ or normalize_flow_step(parameter.schema.applies).casefold() in available
+ )
+ }
+
+
+def _refs(
+ value: object,
+ id_key: str,
+ roles: frozenset[str],
+ path: str,
+ issues: list[dict[str, Any]],
+) -> list[dict[str, str]]:
+ if not isinstance(value, list):
+ issues.append(_issue("invalid_type", path, expected="array"))
+ return []
+ refs = []
+ seen = set()
+ for index, value_item in enumerate(value):
+ item_path = f"{path}/{index}"
+ if not isinstance(value_item, dict):
+ issues.append(_issue("invalid_type", item_path, expected="object"))
+ continue
+ item = {str(key): field for key, field in value_item.items()}
+ _unknown_fields(item, {id_key, "role"}, item_path, issues)
+ item_id = item.get(id_key)
+ role = item.get("role")
+ if not _nonempty_string(item_id):
+ issues.append(_issue("invalid_value", f"{item_path}/{id_key}"))
+ continue
+ if item_id in seen:
+ issues.append(_issue("duplicate_ref", f"{item_path}/{id_key}"))
+ continue
+ seen.add(item_id)
+ if role not in roles:
+ issues.append(_issue("unknown_role", f"{item_path}/role"))
+ continue
+ refs.append({id_key: item_id, "role": role})
+ return refs
+
+
+def _input_bindings(value: object, issues: list[dict[str, Any]]) -> dict[str, str]:
+ if not isinstance(value, dict):
+ issues.append(_issue("invalid_type", "/bindings/inputs", expected="object"))
+ return {}
+ return {
+ key: path for key, path in value.items() if isinstance(key, str) and isinstance(path, str)
+ }
+
+
+def _mapping(value: object, path: str, issues: list[dict[str, Any]]) -> dict[str, Any]:
+ if not isinstance(value, dict):
+ issues.append(_issue("invalid_type", path, expected="object"))
+ return {}
+ return {str(key): item for key, item in value.items()}
+
+
+def _unknown_fields(
+ value: dict[str, Any],
+ allowed: set[str] | frozenset[str],
+ path: str,
+ issues: list[dict[str, Any]],
+) -> None:
+ for key in value.keys() - allowed:
+ issues.append(_issue("unknown_field", f"{path}/{_pointer(key)}"))
+
+
+def _required_string(
+ value: dict[str, Any], key: str, path: str, issues: list[dict[str, Any]]
+) -> None:
+ if not _nonempty_string(value.get(key)):
+ issues.append(_issue("required", path))
+
+
+def _nonempty_string(value: object) -> bool:
+ return isinstance(value, str) and bool(value.strip())
+
+
+def _enum_value(value: object) -> str:
+ return str(getattr(value, "value", value))
+
+
+def _pointer(value: str) -> str:
+ return value.replace("~", "~0").replace("/", "~1")
+
+
+def _issue(code: str, path: str, **details: Any) -> dict[str, Any]:
+ return {"code": code, "path": path, "severity": "error", "details": details}
diff --git a/chipcompiler/project/__init__.py b/chipcompiler/project/__init__.py
new file mode 100644
index 00000000..98ff93c2
--- /dev/null
+++ b/chipcompiler/project/__init__.py
@@ -0,0 +1,15 @@
+from .api import (
+ create_project_manifest,
+ create_project_workspace,
+ discover_project_manifest,
+ load_project_manifest,
+ mutate_project_manifest,
+)
+
+__all__ = [
+ "create_project_manifest",
+ "create_project_workspace",
+ "discover_project_manifest",
+ "load_project_manifest",
+ "mutate_project_manifest",
+]
diff --git a/chipcompiler/project/api.py b/chipcompiler/project/api.py
new file mode 100644
index 00000000..4662f38e
--- /dev/null
+++ b/chipcompiler/project/api.py
@@ -0,0 +1,279 @@
+import json
+import shutil
+from copy import deepcopy
+from datetime import UTC, datetime
+from pathlib import Path
+
+from chipcompiler.cli.project.manifest import (
+ _CANONICAL_TO_MANIFEST_STEP,
+ ManifestError,
+ load_manifest,
+)
+from chipcompiler.cli.project.manifest_write import (
+ build_project_document,
+ manifest_workspace_entry,
+ update_manifest,
+ write_manifest_if_absent,
+)
+
+
+def load_project_manifest(project_dir: str | Path) -> dict:
+ manifest = load_manifest(str(Path(project_dir).expanduser().resolve()))
+ document = deepcopy(manifest.raw)
+ document.update(
+ {
+ "schema_version": 1,
+ "project_id": manifest.project_id,
+ "name": manifest.name,
+ "design_name": manifest.design_name,
+ "root_path": manifest.project_dir,
+ "base_design": deepcopy(manifest.base_design),
+ "objectives": deepcopy(manifest.objectives),
+ "workspaces": [_workspace_document(item) for item in manifest.workspaces],
+ "qor_baseline": deepcopy(manifest.qor_baseline),
+ }
+ )
+ return document
+
+
+def discover_project_manifest(directory: str | Path) -> tuple[Path, dict] | None:
+ current = Path(directory).expanduser().resolve()
+ if not current.is_dir():
+ current = current.parent
+ for project in (current, *current.parents):
+ if (project / "project.json").exists() or (project / "project.json").is_symlink():
+ return project, load_project_manifest(project)
+ return None
+
+
+def create_project_manifest(
+ project_dir: str | Path,
+ name: str,
+ design_name: str,
+ *,
+ now: str | None = None,
+ mpc: dict | None = None,
+) -> dict:
+ project = Path(project_dir).expanduser().resolve()
+ project.mkdir(parents=True, exist_ok=True)
+ document = build_project_document(
+ str(project),
+ design_name=design_name,
+ base_design={"rtl_list": [], "parameters": {"design": design_name}},
+ name=name,
+ now=now,
+ mpc=mpc,
+ )
+ if not write_manifest_if_absent(str(project), document):
+ raise ManifestError(f"Project Manifest already exists: {project / 'project.json'}")
+ try:
+ return load_project_manifest(project)
+ except Exception:
+ (project / "project.json").unlink(missing_ok=True)
+ raise
+
+
+def mutate_project_manifest(project_dir: str | Path, mutation: dict) -> dict:
+ project = Path(project_dir).expanduser().resolve()
+ load_project_manifest(project)
+
+ def apply(document: dict) -> None:
+ kind = mutation.get("type")
+ if kind == "register_workspace":
+ _register_workspace(document, mutation, project)
+ elif kind in {"select_qor_baseline", "select_best_workspace"}:
+ _select_workspace(document, mutation)
+ elif kind == "archive_workspace":
+ _archive_workspace(document, mutation)
+ elif kind == "delete_workspace":
+ _delete_workspace(document, mutation)
+ else:
+ raise ManifestError(f"unsupported Project mutation: {kind}")
+
+ if not update_manifest(str(project), apply):
+ raise ManifestError("Project Manifest update failed")
+ return load_project_manifest(project)
+
+
+def create_project_workspace(
+ project_dir: str | Path,
+ target_directory: str | Path,
+ spec: object,
+ bindings: object,
+ *,
+ command_id: str,
+ workspace_id: str | None = None,
+ name: str | None = None,
+ source_workspace_id: str | None = None,
+ expected_project_id: str | None = None,
+ now: str | None = None,
+):
+ from chipcompiler.engine import create_workspace_from_spec
+
+ project = Path(project_dir).expanduser().resolve()
+ target = Path(target_directory).expanduser().resolve()
+ try:
+ target.relative_to(project)
+ except ValueError as exc:
+ raise ManifestError("Workspace must be inside the Project root") from exc
+ manifest = load_project_manifest(project)
+ if expected_project_id is not None and manifest["project_id"] != expected_project_id:
+ raise ManifestError("Project identity does not match")
+ existed = target.exists()
+ workspace = create_workspace_from_spec(str(target), spec, bindings, command_id)
+ if workspace is None:
+ raise ManifestError("Workspace creation returned no Workspace")
+ identity = workspace_id or target.name
+ try:
+ mutate_project_manifest(
+ project,
+ {
+ "type": "register_workspace",
+ "workspace_id": identity,
+ "name": name or identity,
+ "workspace_path": str(target),
+ "source_workspace_id": source_workspace_id,
+ "created_at": now,
+ "updated_at": now,
+ },
+ )
+ except Exception:
+ if not existed:
+ shutil.rmtree(target, ignore_errors=True)
+ raise
+ return workspace
+
+
+def _workspace_document(workspace) -> dict:
+ document = deepcopy(workspace.raw)
+ document.update(
+ {
+ "workspace_id": workspace.workspace_id,
+ "workspace_path": workspace.workspace_path,
+ "start_step": workspace.start_step,
+ "end_step": workspace.end_step,
+ "status": workspace.status,
+ "parameter_patch": deepcopy(workspace.parameter_patch),
+ }
+ )
+ return document
+
+
+def _register_workspace(document: dict, mutation: dict, project: Path) -> None:
+ workspace_id = _required_string(mutation, "workspace_id")
+ workspace_path = _resolve_workspace(project, _required_string(mutation, "workspace_path"))
+ for existing in document.get("workspaces", []):
+ same_id = existing.get("workspace_id") == workspace_id
+ same_path = (
+ _resolve_workspace(project, str(existing.get("workspace_path", ""))) == workspace_path
+ )
+ if same_id and same_path:
+ return
+ if same_id or same_path:
+ raise ManifestError(f"Workspace registration conflicts with: {workspace_id}")
+ start_step, end_step = _workspace_range(workspace_path, mutation)
+ timestamp = str(
+ mutation.get("updated_at") or mutation.get("created_at") or datetime.now(UTC).isoformat()
+ )
+ entry = manifest_workspace_entry(
+ workspace_id,
+ name=str(mutation.get("name") or workspace_id),
+ workspace_path=str(workspace_path),
+ start_step=start_step,
+ end_step=end_step,
+ status="archived" if mutation.get("lifecycle") == "archived" else "not_started",
+ now=timestamp,
+ )
+ for key in (
+ "source_workspace_id",
+ "branch_from",
+ "parameter_patch",
+ "metrics_summary",
+ "step_metrics",
+ ):
+ if key in mutation:
+ entry[key] = deepcopy(mutation[key])
+ document.setdefault("workspaces", []).append(entry)
+ document["updated_at"] = timestamp
+
+
+def _workspace_range(workspace: Path, mutation: dict) -> tuple[str, str]:
+ start = mutation.get("start_step")
+ end = mutation.get("end_step")
+ if isinstance(start, str) and isinstance(end, str) and start and end:
+ return start, end
+ try:
+ ledger = json.loads((workspace / "home" / "flow.json").read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return "Synth", "Harden"
+ names = [
+ step.get("name")
+ for step in ledger.get("steps", [])
+ if isinstance(step, dict) and isinstance(step.get("name"), str)
+ ]
+ if not names:
+ return "Synth", "Harden"
+ return (
+ _CANONICAL_TO_MANIFEST_STEP.get(names[0], names[0]),
+ _CANONICAL_TO_MANIFEST_STEP.get(names[-1], names[-1]),
+ )
+
+
+def _select_workspace(document: dict, mutation: dict) -> None:
+ workspace_id = _required_string(mutation, "workspace_id")
+ workspace = _find_workspace(document, workspace_id)
+ if workspace.get("status") == "archived":
+ raise ManifestError(f"Workspace is not active: {workspace_id}")
+ field = "qor_baseline" if mutation["type"] == "select_qor_baseline" else "best_workspace"
+ document[field] = {"workspace_id": workspace_id, "reason": str(mutation.get("reason") or "")}
+ document["updated_at"] = str(mutation.get("updated_at") or "")
+
+
+def _archive_workspace(document: dict, mutation: dict) -> None:
+ workspace = _find_workspace(document, _required_string(mutation, "workspace_id"))
+ workspace["status"] = "archived"
+ workspace["updated_at"] = str(mutation.get("updated_at") or "")
+ document["updated_at"] = workspace["updated_at"]
+
+
+def _delete_workspace(document: dict, mutation: dict) -> None:
+ workspace_id = _required_string(mutation, "workspace_id")
+ _find_workspace(document, workspace_id)
+ document["workspaces"] = [
+ workspace
+ for workspace in document.get("workspaces", [])
+ if workspace.get("workspace_id") != workspace_id
+ ]
+ for workspace in document["workspaces"]:
+ if workspace.get("source_workspace_id") == workspace_id:
+ workspace["source_workspace_id"] = None
+ for field in ("qor_baseline", "best_workspace"):
+ selection = document.get(field)
+ if isinstance(selection, dict) and selection.get("workspace_id") == workspace_id:
+ document[field] = None
+ document["updated_at"] = str(mutation.get("updated_at") or "")
+
+
+def _find_workspace(document: dict, workspace_id: str) -> dict:
+ for workspace in document.get("workspaces", []):
+ if isinstance(workspace, dict) and workspace.get("workspace_id") == workspace_id:
+ return workspace
+ raise ManifestError(f"Workspace is not registered: {workspace_id}")
+
+
+def _required_string(value: dict, key: str) -> str:
+ result = value.get(key)
+ if not isinstance(result, str) or not result.strip():
+ raise ManifestError(f"Project Manifest {key} is required")
+ return result.strip()
+
+
+def _resolve_workspace(project: Path, declared: str) -> Path:
+ workspace = Path(declared)
+ workspace = workspace if workspace.is_absolute() else project / workspace
+ resolved = workspace.resolve()
+ try:
+ resolved.relative_to(project)
+ except ValueError as exc:
+ raise ManifestError("Workspace must be inside the Project root") from exc
+ return resolved
diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py
new file mode 100644
index 00000000..fbe833d2
--- /dev/null
+++ b/test/engine/test_workspace_spec.py
@@ -0,0 +1,151 @@
+import json
+from copy import deepcopy
+from pathlib import Path
+
+from chipcompiler.engine.workspace_spec import (
+ describe_workspace_spec,
+ validate_workspace_spec,
+)
+
+
+def _valid_spec():
+ return {
+ "schemaVersion": 1,
+ "design": {"name": "gcd", "topModule": "gcd", "clockPort": "clk"},
+ "inputMode": "rtl",
+ "inputs": [{"inputId": "rtl-main", "role": "rtl"}],
+ "pdk": {"familyId": "ics55", "mode": "default"},
+ "flow": {"flowId": "rtl2gds"},
+ "parameters": {"design.frequency_mhz": 200.0},
+ }
+
+
+def _shared_fixture(name: str):
+ root = Path(__file__).parents[1] / "fixtures" / "workspace_spec"
+ payload = json.loads((root / name).read_text(encoding="utf-8"))
+ bindings = deepcopy(payload["workspaceBindings"])
+ bindings["inputs"] = {key: str(root / value) for key, value in bindings["inputs"].items()}
+ bindings["pdk"]["root"] = str(root / bindings["pdk"]["root"])
+ return payload, bindings
+
+
+def test_shared_workspace_spec_fixtures_cover_valid_and_invalid_contracts():
+ valid, valid_bindings = _shared_fixture("valid.json")
+ assert validate_workspace_spec(valid["workspaceSpec"], valid_bindings)["issues"] == []
+
+ invalid, invalid_bindings = _shared_fixture("invalid.json")
+ result = validate_workspace_spec(invalid["workspaceSpec"], invalid_bindings)
+ assert {issue["code"] for issue in result["issues"]} >= set(invalid["expectedIssueCodes"])
+
+
+def test_workspace_spec_discovery_and_validation_are_canonical_and_side_effect_free(tmp_path):
+ rtl = tmp_path / "gcd.v"
+ rtl.write_text("module gcd(input clk); endmodule\n")
+ pdk = tmp_path / "pdk"
+ pdk.mkdir()
+ before = sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*"))
+
+ discovery = describe_workspace_spec()
+ result = validate_workspace_spec(
+ _valid_spec(),
+ {"inputs": {"rtl-main": str(rtl)}, "pdk": {"root": str(pdk)}},
+ )
+
+ assert discovery["schemaVersion"] == 1
+ assert {item["id"] for item in discovery["parameterCatalog"]} >= {
+ "design.frequency_mhz",
+ "floorplan.core_util",
+ }
+ assert result["issues"] == []
+ assert result["resolvedWorkspaceSpec"]["parameters"]["design.frequency_mhz"] == 200.0
+ assert result["resolvedWorkspaceSpec"]["parameters"]["floorplan.core_util"] == 0.4
+ assert len(result["resolvedWorkspaceSpec"]["pdk"]["contentHash"]) == 64
+ assert str(pdk) not in json.dumps(result["resolvedWorkspaceSpec"])
+ assert sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")) == before
+
+
+def test_workspace_spec_reports_structured_binding_and_input_cardinality_issues(tmp_path):
+ filelist = tmp_path / "sources.f"
+ filelist.write_text("missing.v\n")
+ spec = _valid_spec()
+ spec["inputs"] = [
+ {"inputId": "rtl-main", "role": "rtl"},
+ {"inputId": "sources", "role": "filelist"},
+ ]
+
+ result = validate_workspace_spec(
+ spec,
+ {
+ "inputs": {"sources": str(filelist), "unknown": str(filelist)},
+ "pdk": {"root": str(tmp_path)},
+ },
+ )
+
+ assert {(issue["code"], issue["path"]) for issue in result["issues"]} >= {
+ ("conflicting_input_roles", "/inputs"),
+ ("input_binding_missing", "/bindings/inputs/rtl-main"),
+ ("unknown_input_binding", "/bindings/inputs/unknown"),
+ ("filelist_source_missing", "/bindings/inputs/sources"),
+ }
+ assert "resolvedWorkspaceSpec" not in result
+
+
+def test_workspace_spec_rejects_multiple_manual_pdk_technology_files(tmp_path):
+ rtl = tmp_path / "gcd.v"
+ rtl.write_text("module gcd; endmodule\n")
+ for name in ("a.tech.lef", "b.tech.lef", "cells.lef", "typ.lib"):
+ (tmp_path / name).write_text(name)
+ spec = _valid_spec()
+ spec["pdk"] = {
+ "familyId": "ics55",
+ "mode": "manual",
+ "files": [
+ {"fileId": "tech-a", "role": "tech"},
+ {"fileId": "tech-b", "role": "tech"},
+ {"fileId": "cells", "role": "lef"},
+ {"fileId": "lib", "role": "liberty"},
+ ],
+ }
+
+ result = validate_workspace_spec(
+ json.loads(json.dumps(spec)),
+ {
+ "inputs": {"rtl-main": str(rtl)},
+ "pdk": {
+ "root": str(tmp_path),
+ "files": {
+ "tech-a": str(tmp_path / "a.tech.lef"),
+ "tech-b": str(tmp_path / "b.tech.lef"),
+ "cells": str(tmp_path / "cells.lef"),
+ "lib": str(tmp_path / "typ.lib"),
+ },
+ },
+ },
+ )
+
+ assert any(issue["code"] == "pdk_tech_cardinality" for issue in result["issues"])
+
+
+def test_validated_workspace_spec_creates_main_compatible_workspace(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ from chipcompiler.data import load_workspace
+ from chipcompiler.engine import create_workspace_from_spec
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ target = tmp_path / "workspace"
+
+ created = create_workspace_from_spec(
+ target,
+ payload["workspaceSpec"],
+ bindings,
+ "create-1",
+ )
+ reopened = load_workspace(target)
+
+ assert created.directory == target
+ assert reopened.directory == target
+ assert reopened.design.name == "gcd"
+ assert reopened.parameters.data["frequency_max"] == 200.0
+ assert reopened.flow.steps()[0]["name"] == "Synthesis"
diff --git a/test/fixtures/workspace_spec/input/gcd.v b/test/fixtures/workspace_spec/input/gcd.v
new file mode 100644
index 00000000..5705cb05
--- /dev/null
+++ b/test/fixtures/workspace_spec/input/gcd.v
@@ -0,0 +1,3 @@
+module gcd(input clk, output y);
+ assign y = clk;
+endmodule
diff --git a/test/fixtures/workspace_spec/input/sources.f b/test/fixtures/workspace_spec/input/sources.f
new file mode 100644
index 00000000..369c2e56
--- /dev/null
+++ b/test/fixtures/workspace_spec/input/sources.f
@@ -0,0 +1 @@
+missing.v
diff --git a/test/fixtures/workspace_spec/invalid.json b/test/fixtures/workspace_spec/invalid.json
new file mode 100644
index 00000000..b179d679
--- /dev/null
+++ b/test/fixtures/workspace_spec/invalid.json
@@ -0,0 +1,67 @@
+{
+ "cliConfig": {
+ "design_name": "gcd",
+ "design_rtl": ["input/gcd.v"],
+ "design_top": "gcd",
+ "flow_preset": "rtl2gds",
+ "pdk_name": "ics55",
+ "pdk_overrides": {
+ "mystery": "unsupported"
+ },
+ "pdk_root": "pdk"
+ },
+ "expectedCliError": "unsupported_legacy_field: pdk.overrides.mystery",
+ "expectedIssueCodes": [
+ "conflicting_input_roles",
+ "filelist_source_missing"
+ ],
+ "expectedStudioError": "unsupported_legacy_field: parameters.mystery_knob",
+ "studioDraft": {
+ "commandId": "workspace-create-invalid-shared-fixture",
+ "designInputMode": "rtl",
+ "directory": "workspace",
+ "originVerilog": "input/gcd.v",
+ "parameters": {
+ "design": "gcd",
+ "mystery_knob": 3,
+ "top_module": "gcd"
+ },
+ "pdk": "ics55",
+ "pdkRoot": "pdk"
+ },
+ "workspaceBindings": {
+ "inputs": {
+ "filelist": "input/sources.f",
+ "rtl-main": "input/gcd.v"
+ },
+ "pdk": {
+ "root": "pdk"
+ }
+ },
+ "workspaceSpec": {
+ "design": {
+ "name": "gcd",
+ "topModule": "gcd"
+ },
+ "flow": {
+ "flowId": "rtl2gds"
+ },
+ "inputMode": "rtl",
+ "inputs": [
+ {
+ "inputId": "rtl-main",
+ "role": "rtl"
+ },
+ {
+ "inputId": "filelist",
+ "role": "filelist"
+ }
+ ],
+ "parameters": {},
+ "pdk": {
+ "familyId": "ics55",
+ "mode": "default"
+ },
+ "schemaVersion": 1
+ }
+}
diff --git a/test/fixtures/workspace_spec/pdk/.keep b/test/fixtures/workspace_spec/pdk/.keep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/test/fixtures/workspace_spec/pdk/.keep
@@ -0,0 +1 @@
+
diff --git a/test/fixtures/workspace_spec/valid.json b/test/fixtures/workspace_spec/valid.json
new file mode 100644
index 00000000..94a43fe0
--- /dev/null
+++ b/test/fixtures/workspace_spec/valid.json
@@ -0,0 +1,68 @@
+{
+ "cliConfig": {
+ "design_clock_port": "clk",
+ "design_frequency_mhz": 200,
+ "design_name": "gcd",
+ "design_rtl": ["input/gcd.v"],
+ "design_top": "gcd",
+ "flow_preset": "rtl2gds",
+ "params_overrides": {
+ "floorplan.core_util": 0.6,
+ "place.target_density": 0.7
+ },
+ "pdk_name": "ics55",
+ "pdk_root": "pdk"
+ },
+ "studioDraft": {
+ "commandId": "workspace-create-shared-fixture",
+ "designInputMode": "rtl",
+ "directory": "workspace",
+ "originVerilog": "input/gcd.v",
+ "parameters": {
+ "clock": "clk",
+ "core_utilization": 0.6,
+ "design": "gcd",
+ "frequency_max": 200,
+ "target_density": 0.7,
+ "top_module": "gcd"
+ },
+ "pdk": "ics55",
+ "pdkConfigMode": "default",
+ "pdkRoot": "pdk"
+ },
+ "workspaceBindings": {
+ "inputs": {
+ "rtl-main": "input/gcd.v"
+ },
+ "pdk": {
+ "root": "pdk"
+ }
+ },
+ "workspaceSpec": {
+ "design": {
+ "clockPort": "clk",
+ "name": "gcd",
+ "topModule": "gcd"
+ },
+ "flow": {
+ "flowId": "rtl2gds"
+ },
+ "inputMode": "rtl",
+ "inputs": [
+ {
+ "inputId": "rtl-main",
+ "role": "rtl"
+ }
+ ],
+ "parameters": {
+ "design.frequency_mhz": 200.0,
+ "floorplan.core_util": 0.6,
+ "place.target_density": 0.7
+ },
+ "pdk": {
+ "familyId": "ics55",
+ "mode": "default"
+ },
+ "schemaVersion": 1
+ }
+}
diff --git a/test/project/__init__.py b/test/project/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/project/test_manifest.py b/test/project/test_manifest.py
new file mode 100644
index 00000000..c93ed17e
--- /dev/null
+++ b/test/project/test_manifest.py
@@ -0,0 +1,53 @@
+import json
+
+from chipcompiler.cli.project.manifest import load_manifest
+from chipcompiler.project import (
+ create_project_manifest,
+ discover_project_manifest,
+ load_project_manifest,
+ mutate_project_manifest,
+)
+
+
+def test_domain_created_manifest_is_cli_readable(tmp_path):
+ created = create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z")
+
+ loaded = load_manifest(str(tmp_path))
+
+ assert created["root_path"] == str(tmp_path.resolve())
+ assert loaded.project_id == created["project_id"]
+ assert loaded.design_name == "gcd"
+ assert loaded.workspaces == ()
+
+
+def test_project_manifest_is_discovered_from_nested_workspace(tmp_path):
+ workspace = tmp_path / "experiment"
+ workspace.mkdir()
+ created = create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z")
+
+ assert discover_project_manifest(workspace) == (tmp_path.resolve(), created)
+
+
+def test_register_workspace_uses_main_manifest_shape(tmp_path):
+ workspace = tmp_path / "experiment"
+ (workspace / "home").mkdir(parents=True)
+ (workspace / "home" / "flow.json").write_text(
+ json.dumps({"steps": [{"name": "Synthesis", "state": "Unstart"}]})
+ )
+ create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z")
+
+ updated = mutate_project_manifest(
+ tmp_path,
+ {
+ "type": "register_workspace",
+ "workspace_id": "experiment",
+ "workspace_path": str(workspace),
+ "name": "Experiment",
+ "created_at": "2026-02-01T00:00:00Z",
+ "updated_at": "2026-02-01T00:00:00Z",
+ },
+ )
+
+ assert updated == load_project_manifest(tmp_path)
+ assert updated["workspaces"][0]["start_step"] == "Synth"
+ assert updated["workspaces"][0]["end_step"] == "Synth"
From 02929ef9d93ed7c8f84bfc2a88c9ea00950b9b9a Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 00:33:49 +0800
Subject: [PATCH 03/25] feat(workspace): add revision-aware configuration
updates
---
chipcompiler/cli/project/workspace_params.py | 8 +-
chipcompiler/engine/__init__.py | 8 +
chipcompiler/engine/snapshot.py | 172 ++++++++++++
.../engine/workspace_configuration.py | 253 ++++++++++++++++++
chipcompiler/engine/workspace_lifecycle.py | 88 +++++-
test/engine/test_workspace_configuration.py | 94 +++++++
6 files changed, 620 insertions(+), 3 deletions(-)
create mode 100644 chipcompiler/engine/snapshot.py
create mode 100644 chipcompiler/engine/workspace_configuration.py
create mode 100644 test/engine/test_workspace_configuration.py
diff --git a/chipcompiler/cli/project/workspace_params.py b/chipcompiler/cli/project/workspace_params.py
index 07203d06..d6eb117d 100644
--- a/chipcompiler/cli/project/workspace_params.py
+++ b/chipcompiler/cli/project/workspace_params.py
@@ -48,6 +48,12 @@ def workspace_param_value(workspace, schema) -> object:
def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]:
+ baseline = update_workspace_param_value(workspace, schema, value)
+ return baseline, workspace_param_step(schema)
+
+
+def update_workspace_param_value(workspace, schema, value: object) -> object:
+ """Apply and track one public parameter without choosing invalidation scope."""
records = _override_records(workspace.parameters.data)
record = next((item for item in records if item["key"] == schema.param), None)
if record is None:
@@ -59,7 +65,7 @@ def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]:
record["value"] = deepcopy(value)
_set_override_records(workspace.parameters.data, records)
_apply_workspace_value(workspace, schema, value)
- return record["baseline"], workspace_param_step(schema)
+ return record["baseline"]
def unset_workspace_param(workspace, schema) -> tuple[object, str] | None:
diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py
index 459a008e..98c13608 100644
--- a/chipcompiler/engine/__init__.py
+++ b/chipcompiler/engine/__init__.py
@@ -2,6 +2,11 @@
from .flow import EngineFlow
from .rerun import StepRunResult
from .signoff import SignoffPackageCollector, SignoffPackageOptions
+from .workspace_configuration import (
+ read_workspace_configuration,
+ read_workspace_configuration_from_directory,
+ update_workspace_configuration,
+)
from .workspace_lifecycle import WorkspaceLifecycleError, create_workspace_from_spec
from .workspace_spec import describe_workspace_spec, validate_workspace_spec
@@ -14,5 +19,8 @@
"WorkspaceLifecycleError",
"create_workspace_from_spec",
"describe_workspace_spec",
+ "read_workspace_configuration",
+ "read_workspace_configuration_from_directory",
+ "update_workspace_configuration",
"validate_workspace_spec",
]
diff --git a/chipcompiler/engine/snapshot.py b/chipcompiler/engine/snapshot.py
new file mode 100644
index 00000000..e2e36426
--- /dev/null
+++ b/chipcompiler/engine/snapshot.py
@@ -0,0 +1,172 @@
+from copy import deepcopy
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from chipcompiler.utility import JsonReadError, json_read, json_read_strict, json_write
+
+SNAPSHOT_SCHEMA_VERSION = 2
+SNAPSHOT_FILENAME = "engineering-snapshot.json"
+STALE_SNAPSHOT_FILENAME = "engineering-snapshot.stale.json"
+
+
+class EngineeringSnapshotError(RuntimeError):
+ pass
+
+
+def create_engineering_snapshot(
+ workspace: Any,
+ *,
+ workspace_id: str | None = None,
+ workspace_revision: int = 1,
+ cause: str = "workspace.created",
+) -> dict[str, Any]:
+ snapshot = _build_snapshot(
+ workspace,
+ workspace_id=workspace_id or f"workspace-{uuid4().hex}",
+ workspace_revision=workspace_revision,
+ cause=cause,
+ )
+ _write_snapshot(_snapshot_path(workspace), snapshot)
+ return snapshot
+
+
+def ensure_engineering_snapshot(workspace: Any) -> dict[str, Any]:
+ path = _snapshot_path(workspace)
+ if path.is_file():
+ return _read_snapshot(path)
+ return create_engineering_snapshot(workspace, cause="workspace.migrated")
+
+
+def read_engineering_snapshot(workspace: Any) -> dict[str, Any]:
+ return _read_snapshot(_snapshot_path(workspace))
+
+
+def read_engineering_snapshot_from_directory(directory: str | Path) -> dict[str, Any]:
+ return _read_snapshot(Path(directory).expanduser().resolve() / "home" / SNAPSHOT_FILENAME)
+
+
+def read_stale_engineering_snapshot(workspace: Any) -> dict[str, Any] | None:
+ path = _stale_snapshot_path(workspace)
+ return _read_snapshot(path) if path.is_file() else None
+
+
+def invalidate_engineering_snapshot(
+ workspace: Any,
+ *,
+ workspace_id: str,
+ cause: str,
+ first_invalidated_step: str | None = None,
+) -> dict[str, Any]:
+ current = read_engineering_snapshot(workspace)
+ if current["workspaceId"] != workspace_id:
+ raise EngineeringSnapshotError("Workspace identity changed before invalidation")
+ flow = deepcopy(current.get("flow", {}))
+ steps = flow.get("steps", []) if isinstance(flow, dict) else []
+ invalidated_index = 0
+ if first_invalidated_step is not None:
+ invalidated_index = next(
+ (
+ index
+ for index, step in enumerate(steps)
+ if isinstance(step, dict)
+ and str(step.get("name", "")).casefold() == first_invalidated_step.casefold()
+ ),
+ -1,
+ )
+ if invalidated_index < 0:
+ raise EngineeringSnapshotError(f"Flow Step not found: {first_invalidated_step}")
+ for step in steps[invalidated_index:]:
+ if isinstance(step, dict):
+ step["state"] = "Unstart"
+ step.pop("runtime", None)
+ step.pop("peak memory (mb)", None)
+ stale_path = _stale_snapshot_path(workspace)
+ if not stale_path.is_file():
+ _write_snapshot(stale_path, current)
+ invalidated = [
+ str(step["name"])
+ for step in steps[invalidated_index:]
+ if isinstance(step, dict) and step.get("name")
+ ]
+ snapshot = _build_snapshot(
+ workspace,
+ workspace_id=workspace_id,
+ workspace_revision=current["workspaceRevision"] + 1,
+ cause=cause,
+ )
+ snapshot["flow"] = flow
+ snapshot["stalePredecessor"] = {
+ "workspaceRevision": current["workspaceRevision"],
+ "invalidatedStepIds": invalidated,
+ }
+ _write_snapshot(_snapshot_path(workspace), snapshot)
+ return snapshot
+
+
+def _build_snapshot(
+ workspace: Any,
+ *,
+ workspace_id: str,
+ workspace_revision: int,
+ cause: str,
+) -> dict[str, Any]:
+ flow_owner = getattr(workspace, "flow", None)
+ flow = _data_mapping(flow_owner)
+ if not flow and flow_owner is not None:
+ steps = flow_owner.steps()
+ flow = {"steps": deepcopy(steps)} if steps else {}
+ home = _data_mapping(getattr(workspace, "home", None))
+ checklist_path = home.get("checklist")
+ checklist = json_read(checklist_path) if isinstance(checklist_path, (str, Path)) else {}
+ return {
+ "schemaVersion": SNAPSHOT_SCHEMA_VERSION,
+ "workspaceId": workspace_id,
+ "workspaceRevision": workspace_revision,
+ "cause": cause,
+ "flow": flow,
+ "parameters": _data_mapping(getattr(workspace, "parameters", None)),
+ "checklist": checklist if isinstance(checklist, dict) else {},
+ "analysis": {"steps": []},
+ "metrics": {},
+ "qorAssessment": {"status": "unavailable", "metrics": {}, "dimensions": {}},
+ "signoffAssessment": {"status": "blocked", "groups": [], "risks": []},
+ "artifacts": [],
+ }
+
+
+def _snapshot_path(workspace: Any) -> Path:
+ return Path(workspace.directory) / "home" / SNAPSHOT_FILENAME
+
+
+def _stale_snapshot_path(workspace: Any) -> Path:
+ return Path(workspace.directory) / "home" / STALE_SNAPSHOT_FILENAME
+
+
+def _data_mapping(owner: Any) -> dict[str, Any]:
+ data = getattr(owner, "data", {})
+ return deepcopy(data) if isinstance(data, dict) else {}
+
+
+def _write_snapshot(path: Path, snapshot: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if not json_write(path, snapshot):
+ raise EngineeringSnapshotError(f"failed to persist Engineering Snapshot: {path}")
+
+
+def _read_snapshot(path: Path) -> dict[str, Any]:
+ try:
+ snapshot = json_read_strict(path)
+ except (OSError, JsonReadError) as exc:
+ raise EngineeringSnapshotError(f"invalid Engineering Snapshot: {path}") from exc
+ if (
+ not isinstance(snapshot, dict)
+ or snapshot.get("schemaVersion") != SNAPSHOT_SCHEMA_VERSION
+ or not isinstance(snapshot.get("workspaceId"), str)
+ or not snapshot["workspaceId"]
+ or isinstance(snapshot.get("workspaceRevision"), bool)
+ or not isinstance(snapshot.get("workspaceRevision"), int)
+ or snapshot["workspaceRevision"] < 1
+ ):
+ raise EngineeringSnapshotError(f"invalid Engineering Snapshot: {path}")
+ return snapshot
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
new file mode 100644
index 00000000..57357baa
--- /dev/null
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -0,0 +1,253 @@
+from pathlib import Path
+from typing import Any
+
+from chipcompiler.cli.project.params import (
+ _validate_schema_type,
+ list_schemas,
+ lookup_schema,
+ validate_value,
+)
+from chipcompiler.cli.project.workspace_params import (
+ update_workspace_param_value,
+ workspace_param_value,
+)
+from chipcompiler.data import load_workspace, save_parameter
+from chipcompiler.data.workspace import workspace_config_paths
+from chipcompiler.engine.flow import EngineFlow
+from chipcompiler.engine.rerun import invalidate_from
+from chipcompiler.engine.snapshot import (
+ SNAPSHOT_FILENAME,
+ STALE_SNAPSHOT_FILENAME,
+ ensure_engineering_snapshot,
+ invalidate_engineering_snapshot,
+)
+from chipcompiler.rtl2gds import get_flow_builders
+
+from .workspace_lifecycle import (
+ WorkspaceLifecycleError,
+ _command_retry_matches,
+ _load_committed_workspace,
+ _workspace_command_fingerprint,
+ _write_workspace_command,
+)
+
+
+def update_workspace_configuration(
+ target_directory: str | Path,
+ expected_workspace_revision: int,
+ configuration: object,
+ workspace_bindings: object,
+ command_id: str = "",
+):
+ target = Path(target_directory).expanduser().resolve()
+ if not target.is_dir():
+ raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
+ before = _snapshot_files(_workspace_file_paths(target))
+ try:
+ return _update_workspace_configuration(
+ target,
+ expected_workspace_revision,
+ configuration,
+ workspace_bindings,
+ command_id,
+ )
+ except BaseException:
+ _restore_files(before)
+ raise
+
+
+def _update_workspace_configuration(
+ target: Path,
+ expected_workspace_revision: int,
+ configuration: object,
+ workspace_bindings: object,
+ command_id: str,
+):
+ if not isinstance(configuration, dict) or not isinstance(workspace_bindings, dict):
+ raise WorkspaceLifecycleError(
+ "workspace_spec_invalid", "Workspace configuration and bindings must be objects"
+ )
+ config = {str(key): value for key, value in configuration.items()}
+ unknown_sections = config.keys() - {"design", "pdk", "parameters"}
+ if unknown_sections:
+ raise WorkspaceLifecycleError(
+ "workspace_spec_invalid", f"Unknown configuration section: {min(unknown_sections)}"
+ )
+ if config.get("design") or config.get("pdk"):
+ raise WorkspaceLifecycleError(
+ "workspace_structure_change_requires_update",
+ "Design and PDK changes require a structural Workspace Update",
+ )
+ requested = config.get("parameters", {})
+ if not isinstance(requested, dict):
+ raise WorkspaceLifecycleError("workspace_spec_invalid", "parameters must be an object")
+ parameters = {str(key): value for key, value in requested.items()}
+ fingerprint = _workspace_command_fingerprint(
+ "configuration", config, workspace_bindings, expected_workspace_revision
+ )
+ if command_id and _command_retry_matches(target, command_id, fingerprint):
+ return _load_committed_workspace(target)
+
+ workspace = _load_committed_workspace(target)
+ current = ensure_engineering_snapshot(workspace)
+ if current["workspaceRevision"] != expected_workspace_revision:
+ raise WorkspaceLifecycleError(
+ "revision_conflict",
+ "Workspace Revision does not match",
+ {
+ "expectedWorkspaceRevision": expected_workspace_revision,
+ "actualWorkspaceRevision": current["workspaceRevision"],
+ },
+ )
+
+ changed = False
+ for parameter, value in parameters.items():
+ schema = lookup_schema(parameter)
+ if schema is None or schema.pdk_target is not None:
+ raise WorkspaceLifecycleError("unknown_parameter", f"Unknown parameter: {parameter}")
+ normalized, type_error = _validate_schema_type(value, schema)
+ errors = [type_error] if type_error else validate_value(normalized, schema)
+ if errors:
+ raise WorkspaceLifecycleError("invalid_parameter", str(errors[0]))
+ if workspace_param_value(workspace, schema) != normalized:
+ update_workspace_param_value(workspace, schema, normalized)
+ changed = True
+
+ if not changed:
+ _write_workspace_command(
+ target,
+ command_id,
+ fingerprint,
+ current["workspaceId"],
+ current["workspaceRevision"],
+ )
+ return workspace
+ if not save_parameter(workspace.parameters):
+ raise OSError("Failed to save Workspace parameters")
+ from chipcompiler.data import refresh_workspace_config
+
+ refresh_workspace_config(workspace)
+ flow = EngineFlow(workspace)
+ steps = workspace.flow.steps()
+ if steps:
+ invalidate_from(flow, str(steps[0]["name"]))
+ updated = invalidate_engineering_snapshot(
+ workspace,
+ workspace_id=current["workspaceId"],
+ cause="workspace.configuration_updated",
+ )
+ _write_workspace_command(
+ target,
+ command_id,
+ fingerprint,
+ updated["workspaceId"],
+ updated["workspaceRevision"],
+ )
+ return _load_committed_workspace(target)
+
+
+def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
+ parameters = {}
+ for schema in list_schemas():
+ if schema.pdk_target is not None:
+ continue
+ try:
+ parameters[schema.param] = workspace_param_value(workspace, schema)
+ except (OSError, ValueError):
+ continue
+ steps = workspace.flow.steps()
+ flow_names = [str(step.get("name", "")) for step in steps if step.get("name")]
+ flow = {"flowId": _flow_id(flow_names)}
+ if flow_names:
+ flow.update({"fromStepId": flow_names[0], "throughStepId": flow_names[-1]})
+ inputs, input_bindings = _workspace_inputs(workspace)
+ return {
+ "workspaceSpec": {
+ "schemaVersion": 1,
+ "design": {
+ "name": workspace.design.name,
+ "topModule": workspace.design.top_module,
+ "clockPort": str(workspace.parameters.data.get("clock", "")),
+ },
+ "inputMode": "postSynthesis" if workspace.design.origin_def is not None else "rtl",
+ "inputs": inputs,
+ "pdk": {
+ "familyId": workspace.pdk.name,
+ "version": workspace.pdk.version,
+ "mode": "default",
+ },
+ "flow": flow,
+ "parameters": parameters,
+ },
+ "workspaceBindings": {
+ "inputs": input_bindings,
+ "pdk": {
+ "root": str(workspace.pdk.root or ""),
+ "version": workspace.pdk.version,
+ },
+ },
+ }
+
+
+def read_workspace_configuration_from_directory(
+ target_directory: str | Path,
+) -> dict[str, Any]:
+ target = Path(target_directory).expanduser().resolve()
+ workspace = load_workspace(target)
+ if workspace is None:
+ raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
+ return read_workspace_configuration(workspace)
+
+
+def _workspace_inputs(workspace: Any) -> tuple[list[dict[str, str]], dict[str, str]]:
+ values = []
+ if workspace.design.input_filelist is not None:
+ values.append(("filelist", workspace.design.input_filelist))
+ elif workspace.design.origin_verilog is not None:
+ role = "netlist" if workspace.design.origin_def is not None else "rtl"
+ values.append((role, workspace.design.origin_verilog))
+ for role, path in (
+ ("def", workspace.design.origin_def),
+ ("goldenNetlist", workspace.design.golden_verilog),
+ ("sdc", workspace.pdk.sdc),
+ ("spef", workspace.pdk.spef),
+ ):
+ if path is not None:
+ values.append((role, path))
+ return (
+ [{"inputId": role, "role": role} for role, _path in values],
+ {role: str(path) for role, path in values},
+ )
+
+
+def _flow_id(names: list[str]) -> str:
+ for flow_id, builder in get_flow_builders().items():
+ candidate = [str(getattr(step, "value", step)) for step, _tool, _state in builder()]
+ if candidate == names:
+ return flow_id
+ return "custom"
+
+
+def _workspace_file_paths(target: Path) -> list[Path]:
+ paths = [
+ target / "home" / "params.toml",
+ target / "home" / "flow.json",
+ target / "home" / SNAPSHOT_FILENAME,
+ target / "home" / STALE_SNAPSHOT_FILENAME,
+ target / "home" / "workspace-commands.json",
+ ]
+ paths.extend(path for key, path in workspace_config_paths(target).items() if key != "dir")
+ return paths
+
+
+def _snapshot_files(paths: list[Path]) -> dict[Path, bytes | None]:
+ return {path: path.read_bytes() if path.is_file() else None for path in paths}
+
+
+def _restore_files(snapshot: dict[Path, bytes | None]) -> None:
+ for path, content in snapshot.items():
+ if content is None:
+ path.unlink(missing_ok=True)
+ else:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index ea561c5f..aa41cc74 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -1,10 +1,13 @@
+import hashlib
+import json
import shutil
import tempfile
from pathlib import Path
from typing import Any
from chipcompiler.cli.project.params import build_backend_overrides, resolve_parameters
-from chipcompiler.data import PDK, create_workspace
+from chipcompiler.data import PDK, create_workspace, load_workspace
+from chipcompiler.engine.snapshot import create_engineering_snapshot
from chipcompiler.engine.workspace_spec import validate_workspace_spec
from chipcompiler.rtl2gds import get_flow_builders
@@ -22,9 +25,11 @@ def create_workspace_from_spec(
bindings: object,
command_id: str = "",
):
- del command_id
target = Path(target_directory).expanduser().resolve()
+ fingerprint = _workspace_command_fingerprint("create", spec, bindings)
if target.exists():
+ if command_id and _command_retry_matches(target, command_id, fingerprint):
+ return _load_committed_workspace(target)
raise WorkspaceLifecycleError("workspace_exists", f"Workspace already exists: {target}")
validation = validate_workspace_spec(spec, bindings)
issues = validation["issues"]
@@ -96,6 +101,14 @@ def create_workspace_from_spec(
raise WorkspaceLifecycleError(
"workspace_create_failed", f"Workspace creation failed: {target}"
)
+ snapshot = create_engineering_snapshot(workspace)
+ _write_workspace_command(
+ target,
+ command_id,
+ fingerprint,
+ snapshot["workspaceId"],
+ snapshot["workspaceRevision"],
+ )
return workspace
except Exception:
shutil.rmtree(target, ignore_errors=True)
@@ -142,3 +155,74 @@ def _string_keyed_dict(value: object) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
return {str(key): item for key, item in value.items()}
+
+
+def _load_committed_workspace(path: Path):
+ workspace = load_workspace(path)
+ if workspace is None:
+ raise WorkspaceLifecycleError("workspace_invalid", f"Workspace cannot be opened: {path}")
+ return workspace
+
+
+def _workspace_command_fingerprint(
+ kind: str,
+ spec: object,
+ bindings: object,
+ expected_revision: int | None = None,
+) -> str:
+ payload = json.dumps(
+ {
+ "kind": kind,
+ "spec": spec,
+ "bindings": bindings,
+ "expectedWorkspaceRevision": expected_revision,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _workspace_commands(path: Path) -> dict[str, Any]:
+ command_path = path / "home" / "workspace-commands.json"
+ try:
+ payload = json.loads(command_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return {"schemaVersion": 1, "commands": {}}
+ if payload.get("schemaVersion") != 1 or not isinstance(payload.get("commands"), dict):
+ raise WorkspaceLifecycleError("workspace_invalid", "Invalid Workspace command ledger")
+ return payload
+
+
+def _command_retry_matches(path: Path, command_id: str, fingerprint: str) -> bool:
+ record = _workspace_commands(path)["commands"].get(command_id)
+ if record is None:
+ return False
+ if not isinstance(record, dict) or record.get("fingerprint") != fingerprint:
+ raise WorkspaceLifecycleError(
+ "idempotency_conflict", f"command id reused with different input: {command_id}"
+ )
+ return True
+
+
+def _write_workspace_command(
+ path: Path,
+ command_id: str,
+ fingerprint: str,
+ workspace_id: str,
+ workspace_revision: int,
+) -> None:
+ if not command_id:
+ return
+ from chipcompiler.utility import json_write
+
+ payload = _workspace_commands(path)
+ payload["commands"][command_id] = {
+ "fingerprint": fingerprint,
+ "result": {
+ "workspaceId": workspace_id,
+ "workspaceRevision": workspace_revision,
+ },
+ }
+ if not json_write(path / "home" / "workspace-commands.json", payload):
+ raise OSError("Failed to persist Workspace command ledger")
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
new file mode 100644
index 00000000..dc9caca5
--- /dev/null
+++ b/test/engine/test_workspace_configuration.py
@@ -0,0 +1,94 @@
+import json
+from copy import deepcopy
+from pathlib import Path
+
+import pytest
+
+from chipcompiler.engine import (
+ WorkspaceLifecycleError,
+ create_workspace_from_spec,
+ read_workspace_configuration_from_directory,
+ update_workspace_configuration,
+)
+from chipcompiler.engine.snapshot import read_engineering_snapshot
+
+
+def _workspace_spec_fixture() -> tuple[dict, dict]:
+ root = Path(__file__).parents[1] / "fixtures" / "workspace_spec"
+ payload = json.loads((root / "valid.json").read_text(encoding="utf-8"))
+ bindings = deepcopy(payload["workspaceBindings"])
+ bindings["inputs"] = {key: str(root / value) for key, value in bindings["inputs"].items()}
+ return payload["workspaceSpec"], bindings
+
+
+def test_workspace_configuration_update_is_revision_aware_and_idempotent(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1")
+ initial = read_engineering_snapshot(workspace)
+
+ updated = update_workspace_configuration(
+ workspace.directory,
+ initial["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-1",
+ )
+ current = read_engineering_snapshot(updated)
+ repeated = update_workspace_configuration(
+ workspace.directory,
+ initial["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-1",
+ )
+
+ assert initial["workspaceRevision"] == 1
+ assert current["workspaceRevision"] == 2
+ assert read_engineering_snapshot(repeated)["workspaceRevision"] == 2
+ assert repeated.parameters.data["frequency_max"] == 250.0
+ assert (
+ read_workspace_configuration_from_directory(workspace.directory)["workspaceSpec"][
+ "parameters"
+ ]["design.frequency_mhz"]
+ == 250.0
+ )
+
+ with pytest.raises(WorkspaceLifecycleError) as conflict:
+ update_workspace_configuration(
+ workspace.directory,
+ 1,
+ {"parameters": {"design.frequency_mhz": 300.0}},
+ bindings,
+ "configuration-2",
+ )
+ assert conflict.value.code == "revision_conflict"
+ assert read_engineering_snapshot(updated)["workspaceRevision"] == 2
+
+
+def test_workspace_configuration_update_rolls_back_refresh_failure(
+ tmp_path, minimal_ics55_pdk_factory, monkeypatch
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1")
+ before_config = (workspace.directory / "home" / "params.toml").read_bytes()
+ before_snapshot = read_engineering_snapshot(workspace)
+
+ def fail_refresh(_workspace):
+ raise RuntimeError("config regeneration failed")
+
+ monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", fail_refresh)
+ with pytest.raises(RuntimeError, match="config regeneration failed"):
+ update_workspace_configuration(
+ workspace.directory,
+ before_snapshot["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-1",
+ )
+
+ assert (workspace.directory / "home" / "params.toml").read_bytes() == before_config
+ assert read_engineering_snapshot(workspace) == before_snapshot
From 1c989c8c6b4652d3b308b72a0dfe6c1eec9338dc Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 00:46:47 +0800
Subject: [PATCH 04/25] feat(workspace): add scoped step configuration
---
chipcompiler/engine/__init__.py | 6 +
.../engine/workspace_configuration.py | 177 +++++++++++++++++-
test/engine/test_workspace_configuration.py | 63 ++++++-
3 files changed, 244 insertions(+), 2 deletions(-)
diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py
index 98c13608..a6449663 100644
--- a/chipcompiler/engine/__init__.py
+++ b/chipcompiler/engine/__init__.py
@@ -3,9 +3,12 @@
from .rerun import StepRunResult
from .signoff import SignoffPackageCollector, SignoffPackageOptions
from .workspace_configuration import (
+ read_step_configuration,
+ read_step_configuration_from_directory,
read_workspace_configuration,
read_workspace_configuration_from_directory,
update_workspace_configuration,
+ update_workspace_step_configuration,
)
from .workspace_lifecycle import WorkspaceLifecycleError, create_workspace_from_spec
from .workspace_spec import describe_workspace_spec, validate_workspace_spec
@@ -19,8 +22,11 @@
"WorkspaceLifecycleError",
"create_workspace_from_spec",
"describe_workspace_spec",
+ "read_step_configuration",
+ "read_step_configuration_from_directory",
"read_workspace_configuration",
"read_workspace_configuration_from_directory",
+ "update_workspace_step_configuration",
"update_workspace_configuration",
"validate_workspace_spec",
]
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
index 57357baa..196e62a6 100644
--- a/chipcompiler/engine/workspace_configuration.py
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -21,7 +22,7 @@
ensure_engineering_snapshot,
invalidate_engineering_snapshot,
)
-from chipcompiler.rtl2gds import get_flow_builders
+from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step
from .workspace_lifecycle import (
WorkspaceLifecycleError,
@@ -189,6 +190,180 @@ def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
}
+def read_step_configuration(workspace: Any, step_id: str) -> dict[str, Any]:
+ step, schemas = _step_catalog(workspace, step_id)
+ snapshot = ensure_engineering_snapshot(workspace)
+ if not schemas:
+ raise WorkspaceLifecycleError(
+ "step_configuration_unavailable",
+ f"Flow Step has no configurable parameters: {step}",
+ {
+ "workspaceId": snapshot["workspaceId"],
+ "workspaceRevision": snapshot["workspaceRevision"],
+ },
+ )
+ return {
+ "step": step,
+ "stepId": step,
+ "parameters": [_public_parameter_record(workspace, schema) for schema in schemas],
+ "workspaceId": snapshot["workspaceId"],
+ "workspaceRevision": snapshot["workspaceRevision"],
+ }
+
+
+def read_step_configuration_from_directory(
+ target_directory: str | Path,
+ step_id: str,
+) -> dict[str, Any]:
+ workspace = _load_committed_workspace(Path(target_directory).expanduser().resolve())
+ return read_step_configuration(workspace, step_id)
+
+
+def update_workspace_step_configuration(
+ target_directory: str | Path,
+ expected_workspace_revision: int,
+ step_id: str,
+ parameters: object,
+ command_id: str = "",
+):
+ target = Path(target_directory).expanduser().resolve()
+ if not target.is_dir():
+ raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
+ before = _snapshot_files(_workspace_file_paths(target))
+ try:
+ return _update_workspace_step_configuration(
+ target,
+ expected_workspace_revision,
+ step_id,
+ parameters,
+ command_id,
+ )
+ except BaseException:
+ _restore_files(before)
+ raise
+
+
+def _update_workspace_step_configuration(
+ target: Path,
+ expected_workspace_revision: int,
+ step_id: str,
+ parameters: object,
+ command_id: str,
+):
+ if not isinstance(step_id, str) or not step_id.strip() or not isinstance(parameters, dict):
+ raise WorkspaceLifecycleError(
+ "workspace_spec_invalid", "Step identity and parameters object are required"
+ )
+ patch = {str(key): value for key, value in parameters.items()}
+ fingerprint = _workspace_command_fingerprint(
+ "step_configuration",
+ {"stepId": step_id, "parameters": patch},
+ {},
+ expected_workspace_revision,
+ )
+ if command_id and _command_retry_matches(target, command_id, fingerprint):
+ return _load_committed_workspace(target)
+
+ workspace = _load_committed_workspace(target)
+ snapshot = ensure_engineering_snapshot(workspace)
+ if snapshot["workspaceRevision"] != expected_workspace_revision:
+ raise WorkspaceLifecycleError(
+ "revision_conflict",
+ "Workspace Revision does not match",
+ {
+ "expectedWorkspaceRevision": expected_workspace_revision,
+ "actualWorkspaceRevision": snapshot["workspaceRevision"],
+ },
+ )
+ step, schemas = _step_catalog(workspace, step_id)
+ allowed = {schema.param: schema for schema in schemas}
+ changed = False
+ for parameter, value in patch.items():
+ schema = lookup_schema(parameter)
+ if schema is None or schema.pdk_target is not None:
+ raise WorkspaceLifecycleError("unknown_parameter", f"Unknown parameter: {parameter}")
+ if parameter not in allowed:
+ raise WorkspaceLifecycleError(
+ "parameter_not_applicable",
+ f"Parameter {parameter} is not configurable at {step}",
+ )
+ normalized, type_error = _validate_schema_type(value, schema)
+ errors = [type_error] if type_error else validate_value(normalized, schema)
+ if errors:
+ raise WorkspaceLifecycleError("invalid_parameter", str(errors[0]))
+ if workspace_param_value(workspace, schema) != normalized:
+ update_workspace_param_value(workspace, schema, normalized)
+ changed = True
+
+ if not changed:
+ _write_workspace_command(
+ target,
+ command_id,
+ fingerprint,
+ snapshot["workspaceId"],
+ snapshot["workspaceRevision"],
+ )
+ return workspace
+ if not save_parameter(workspace.parameters):
+ raise OSError("Failed to save Workspace parameters")
+ from chipcompiler.data import refresh_workspace_config
+
+ refresh_workspace_config(workspace)
+ invalidate_from(EngineFlow(workspace), step)
+ updated = invalidate_engineering_snapshot(
+ workspace,
+ workspace_id=snapshot["workspaceId"],
+ cause="workspace.step_configuration_updated",
+ first_invalidated_step=step,
+ )
+ _write_workspace_command(
+ target,
+ command_id,
+ fingerprint,
+ updated["workspaceId"],
+ updated["workspaceRevision"],
+ )
+ return _load_committed_workspace(target)
+
+
+def _step_catalog(workspace: Any, step_id: str):
+ identity = normalize_flow_step(step_id).casefold()
+ steps = [str(step.get("name", "")) for step in workspace.flow.steps() if step.get("name")]
+ step = next(
+ (candidate for candidate in steps if normalize_flow_step(candidate).casefold() == identity),
+ None,
+ )
+ if step is None:
+ raise WorkspaceLifecycleError("unknown_flow_step", f"Flow Step not found: {step_id}")
+ first = normalize_flow_step(steps[0]).casefold()
+ schemas = tuple(
+ schema
+ for schema in list_schemas()
+ if schema.pdk_target is None
+ and (
+ normalize_flow_step(schema.applies).casefold() == identity
+ or (schema.applies == "all" and identity == first)
+ )
+ )
+ return step, schemas
+
+
+def _public_parameter_record(workspace: Any, schema) -> dict[str, Any]:
+ record = {
+ "param": schema.param,
+ "type": schema.type,
+ "value": workspace_param_value(workspace, schema),
+ "default": deepcopy(schema.default),
+ "applies": schema.applies,
+ "description": schema.description,
+ }
+ for field in ("range", "choices", "unit"):
+ value = getattr(schema, field)
+ if value is not None:
+ record[field] = list(value) if isinstance(value, tuple) else value
+ return record
+
+
def read_workspace_configuration_from_directory(
target_directory: str | Path,
) -> dict[str, Any]:
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
index dc9caca5..2b7702cd 100644
--- a/test/engine/test_workspace_configuration.py
+++ b/test/engine/test_workspace_configuration.py
@@ -5,12 +5,15 @@
import pytest
from chipcompiler.engine import (
+ EngineFlow,
WorkspaceLifecycleError,
create_workspace_from_spec,
+ read_step_configuration,
read_workspace_configuration_from_directory,
update_workspace_configuration,
+ update_workspace_step_configuration,
)
-from chipcompiler.engine.snapshot import read_engineering_snapshot
+from chipcompiler.engine.snapshot import create_engineering_snapshot, read_engineering_snapshot
def _workspace_spec_fixture() -> tuple[dict, dict]:
@@ -92,3 +95,61 @@ def fail_refresh(_workspace):
assert (workspace.directory / "home" / "params.toml").read_bytes() == before_config
assert read_engineering_snapshot(workspace) == before_snapshot
+
+
+def test_step_configuration_update_invalidates_only_target_suffix(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1")
+ flow = EngineFlow(workspace)
+ for step in flow.workspace.flow.data["steps"][:3]:
+ step["state"] = "Success"
+ assert flow.save()
+ initial = read_engineering_snapshot(workspace)
+ create_engineering_snapshot(
+ workspace,
+ workspace_id=initial["workspaceId"],
+ workspace_revision=initial["workspaceRevision"],
+ )
+
+ configuration = read_step_configuration(workspace, "floor-plan")
+ synthesis = read_step_configuration(workspace, "synthesis")
+ updated = update_workspace_step_configuration(
+ workspace.directory,
+ configuration["workspaceRevision"],
+ "Floorplan",
+ {"floorplan.core_util": 0.55},
+ "step-configuration-1",
+ )
+
+ records = {record["param"]: record for record in configuration["parameters"]}
+ synthesis_params = {record["param"] for record in synthesis["parameters"]}
+ assert {"design.frequency_mhz", "flow.run_analysis"} <= synthesis_params
+ assert "floorplan.core_util" in records
+ assert set(records["floorplan.core_util"]) == {
+ "param",
+ "type",
+ "value",
+ "default",
+ "applies",
+ "description",
+ "range",
+ }
+ states = {step["name"]: step["state"] for step in updated.flow.steps()}
+ assert states["Synthesis"] == "Success"
+ assert states["lec"] == "Success"
+ assert states["Floorplan"] == "Unstart"
+ assert read_engineering_snapshot(updated)["workspaceRevision"] == 2
+
+ with pytest.raises(WorkspaceLifecycleError) as inapplicable:
+ update_workspace_step_configuration(
+ workspace.directory,
+ 2,
+ "Floorplan",
+ {"place.target_density": 0.6},
+ "step-configuration-2",
+ )
+ assert inapplicable.value.code == "parameter_not_applicable"
+ assert read_engineering_snapshot(updated)["workspaceRevision"] == 2
From c056ecc442249c056b35ce00e2f94c0a00de7c58 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 00:54:31 +0800
Subject: [PATCH 05/25] feat(engine): add headless execution contracts
---
chipcompiler/engine/__init__.py | 15 +++-
chipcompiler/engine/execution.py | 85 +++++++++++++++++++++
chipcompiler/engine/snapshot.py | 35 +++++++++
chipcompiler/engine/workspace_flow.py | 89 ++++++++++++++++++++++
chipcompiler/engine/workspace_lifecycle.py | 47 +++++++++++-
test/engine/test_execution.py | 50 ++++++++++++
test/engine/test_workspace_flow.py | 14 ++++
7 files changed, 333 insertions(+), 2 deletions(-)
create mode 100644 chipcompiler/engine/execution.py
create mode 100644 chipcompiler/engine/workspace_flow.py
create mode 100644 test/engine/test_execution.py
create mode 100644 test/engine/test_workspace_flow.py
diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py
index a6449663..95a7ad30 100644
--- a/chipcompiler/engine/__init__.py
+++ b/chipcompiler/engine/__init__.py
@@ -1,4 +1,5 @@
from .db import EngineDB
+from .execution import ExecutionPlan, ExecutionResult, execute
from .flow import EngineFlow
from .rerun import StepRunResult
from .signoff import SignoffPackageCollector, SignoffPackageOptions
@@ -10,18 +11,30 @@
update_workspace_configuration,
update_workspace_step_configuration,
)
-from .workspace_lifecycle import WorkspaceLifecycleError, create_workspace_from_spec
+from .workspace_lifecycle import (
+ WorkspaceLifecycleError,
+ apply_workspace_bindings,
+ assess_execution_readiness,
+ create_workspace_from_spec,
+ describe_workspace_binding_requirement,
+)
from .workspace_spec import describe_workspace_spec, validate_workspace_spec
__all__ = [
"EngineDB",
"EngineFlow",
+ "ExecutionPlan",
+ "ExecutionResult",
"StepRunResult",
"SignoffPackageCollector",
"SignoffPackageOptions",
"WorkspaceLifecycleError",
+ "apply_workspace_bindings",
+ "assess_execution_readiness",
"create_workspace_from_spec",
"describe_workspace_spec",
+ "describe_workspace_binding_requirement",
+ "execute",
"read_step_configuration",
"read_step_configuration_from_directory",
"read_workspace_configuration",
diff --git a/chipcompiler/engine/execution.py b/chipcompiler/engine/execution.py
new file mode 100644
index 00000000..e25a2cd0
--- /dev/null
+++ b/chipcompiler/engine/execution.py
@@ -0,0 +1,85 @@
+import inspect
+from dataclasses import dataclass
+from typing import Any, Literal
+
+from chipcompiler.data import StateEnum
+
+
+@dataclass(frozen=True)
+class ExecutionPlan:
+ intent: Literal["run", "rerun"]
+ step_id: str | None = None
+
+
+@dataclass(frozen=True)
+class ExecutionResult:
+ succeeded: bool
+ state: str
+ step_id: str | None = None
+
+
+def execute(flow: Any, plan: ExecutionPlan, *, event_sink: Any = None) -> ExecutionResult:
+ if plan.intent not in {"run", "rerun"}:
+ raise ValueError(f"unsupported execution intent: {plan.intent}")
+ if event_sink is None and getattr(flow.workspace, "directory", None):
+ event_sink = _EngineeringCommitSink(flow.workspace)
+ rerun = plan.intent == "rerun"
+ if plan.step_id is None:
+ succeeded = bool(_invoke(flow.run_steps, rerun=rerun, observer=event_sink))
+ return ExecutionResult(
+ succeeded=succeeded,
+ state=StateEnum.Success.value if succeeded else StateEnum.Imcomplete.value,
+ )
+
+ get_workspace_step = getattr(flow, "get_workspace_step", None)
+ step = (
+ get_workspace_step(plan.step_id)
+ if callable(get_workspace_step)
+ else next(
+ (
+ candidate
+ for candidate in getattr(flow, "workspace_steps", [])
+ if getattr(candidate, "name", None) == plan.step_id
+ ),
+ None,
+ )
+ )
+ if step is None:
+ raise ValueError(f"step not found: {plan.step_id}")
+ state = _invoke(flow.run_step, step, rerun=rerun, observer=event_sink)
+ return ExecutionResult(
+ succeeded=state is StateEnum.Success,
+ state=getattr(state, "value", str(state)),
+ step_id=plan.step_id,
+ )
+
+
+class _EngineeringCommitSink:
+ def __init__(self, workspace: Any):
+ from chipcompiler.engine.snapshot import ensure_engineering_snapshot
+
+ self.workspace = workspace
+ self.snapshot = ensure_engineering_snapshot(workspace)
+
+ def commit_step(self, _step: Any, state: Any, _error: str | None = None) -> None:
+ from chipcompiler.engine.snapshot import commit_engineering_snapshot
+
+ self.snapshot = commit_engineering_snapshot(
+ self.workspace,
+ workspace_id=self.snapshot["workspaceId"],
+ cause=f"flow_step.{getattr(state, 'value', str(state)).lower()}",
+ )
+
+
+def _invoke(callback, *args, rerun: bool, observer: Any):
+ parameters = inspect.signature(callback).parameters.values()
+ accepts_kwargs = any(
+ parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters
+ )
+ kwargs = {}
+ names = {parameter.name for parameter in parameters}
+ if accepts_kwargs or "rerun" in names:
+ kwargs["rerun"] = rerun
+ if observer is not None and (accepts_kwargs or "observer" in names):
+ kwargs["observer"] = observer
+ return callback(*args, **kwargs)
diff --git a/chipcompiler/engine/snapshot.py b/chipcompiler/engine/snapshot.py
index e2e36426..34a6fa6d 100644
--- a/chipcompiler/engine/snapshot.py
+++ b/chipcompiler/engine/snapshot.py
@@ -51,6 +51,41 @@ def read_stale_engineering_snapshot(workspace: Any) -> dict[str, Any] | None:
return _read_snapshot(path) if path.is_file() else None
+def commit_engineering_snapshot(
+ workspace: Any,
+ *,
+ workspace_id: str,
+ cause: str,
+) -> dict[str, Any]:
+ current = read_engineering_snapshot(workspace)
+ if current["workspaceId"] != workspace_id:
+ raise EngineeringSnapshotError("Workspace identity changed before commit")
+ snapshot = _build_snapshot(
+ workspace,
+ workspace_id=workspace_id,
+ workspace_revision=current["workspaceRevision"] + 1,
+ cause=cause,
+ )
+ stale = current.get("stalePredecessor")
+ if isinstance(stale, dict):
+ states = {
+ str(step.get("name")): step.get("state")
+ for step in snapshot.get("flow", {}).get("steps", [])
+ if isinstance(step, dict) and step.get("name")
+ }
+ remaining = [
+ step_id
+ for step_id in stale.get("invalidatedStepIds", [])
+ if states.get(step_id) not in {"Success", "Skipped"}
+ ]
+ if remaining:
+ snapshot["stalePredecessor"] = {**deepcopy(stale), "invalidatedStepIds": remaining}
+ else:
+ _stale_snapshot_path(workspace).unlink(missing_ok=True)
+ _write_snapshot(_snapshot_path(workspace), snapshot)
+ return snapshot
+
+
def invalidate_engineering_snapshot(
workspace: Any,
*,
diff --git a/chipcompiler/engine/workspace_flow.py b/chipcompiler/engine/workspace_flow.py
new file mode 100644
index 00000000..0d10bd13
--- /dev/null
+++ b/chipcompiler/engine/workspace_flow.py
@@ -0,0 +1,89 @@
+from typing import Any
+
+
+def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True):
+ import chipcompiler.engine as engine_api
+
+ engine_flow = engine_api.EngineFlow(workspace=workspace)
+ if not engine_flow.has_init():
+ raise ValueError("Workspace has no committed Flow")
+
+ if create_step_workspaces:
+ engine_flow.create_step_workspaces()
+ return engine_flow
+
+
+def workspace_step_from_flow(workspace, name: str):
+ previous_step = None
+ loader = getattr(workspace.flow, "steps", None)
+ steps = loader() if callable(loader) else workspace.flow.data.get("steps", [])
+ for flow_step in steps:
+ workspace_step = _build_workspace_step_for_info(workspace, flow_step, previous_step)
+ if flow_step.get("name") == name:
+ return workspace_step
+ if workspace_step is not None:
+ previous_step = workspace_step
+ return None
+
+
+def _build_workspace_step_for_info(workspace, flow_step: dict, previous_step):
+ step_name = flow_step.get("name")
+ tool = flow_step.get("tool")
+ if not step_name or not tool:
+ return None
+
+ if previous_step is None:
+ input_def = workspace.design.origin_def
+ input_verilog = workspace.design.origin_verilog
+ input_db = None
+ else:
+ input_def = previous_step.output.def_ or ""
+ input_verilog = previous_step.output.verilog or ""
+ input_db = previous_step.output.db or ""
+
+ builder = _load_tool_builder(tool)
+ if builder is None or not hasattr(builder, "build_step"):
+ return None
+
+ return builder.build_step(
+ workspace=workspace,
+ step_name=step_name,
+ input_def=input_def,
+ input_verilog=input_verilog,
+ input_db=input_db,
+ )
+
+
+def _load_tool_builder(tool: str):
+ import importlib
+
+ module_alias = {
+ "klayout": "klayout_tool",
+ "dreamplace": "ecc_dreamplace",
+ "sizer": "ecc_sizer",
+ }
+ module_name = module_alias.get(tool, tool)
+ return importlib.import_module(f"chipcompiler.tools.{module_name}.builder")
+
+
+def init_db_engine_for_workspace_step(engine_flow, workspace_step):
+ engine_db = getattr(engine_flow, "engine_db", None)
+ if engine_db is None:
+ from chipcompiler.engine import EngineDB
+
+ engine_db = EngineDB(workspace=engine_flow.workspace)
+ engine_flow.engine_db = engine_db
+ elif engine_db.has_init():
+ return True
+
+ return engine_db.create_db_engine(step=workspace_step)
+
+
+def success_state():
+ from chipcompiler.data import StateEnum
+
+ return StateEnum.Success
+
+
+def state_value(state: Any) -> str:
+ return getattr(state, "value", str(state))
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index aa41cc74..679e4ca5 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -6,7 +6,7 @@
from typing import Any
from chipcompiler.cli.project.params import build_backend_overrides, resolve_parameters
-from chipcompiler.data import PDK, create_workspace, load_workspace
+from chipcompiler.data import PDK, create_workspace, get_pdk, load_workspace
from chipcompiler.engine.snapshot import create_engineering_snapshot
from chipcompiler.engine.workspace_spec import validate_workspace_spec
from chipcompiler.rtl2gds import get_flow_builders
@@ -19,6 +19,51 @@ def __init__(self, code: str, message: str, details: dict[str, Any] | None = Non
self.details = details or {}
+def describe_workspace_binding_requirement(
+ workspace_directory: str | Path,
+) -> dict[str, Any]:
+ workspace = _load_committed_workspace(Path(workspace_directory).expanduser().resolve())
+ return {
+ "familyId": workspace.pdk.name,
+ "version": workspace.pdk.version or "unversioned",
+ "mode": "default",
+ }
+
+
+def assess_execution_readiness(
+ workspace_directory: str | Path,
+ bindings: object | None,
+) -> dict[str, Any]:
+ try:
+ workspace = _load_committed_workspace(Path(workspace_directory).expanduser().resolve())
+ except (OSError, ValueError, WorkspaceLifecycleError):
+ return {"ready": False, "code": "workspace_invalid"}
+ binding_map = _string_keyed_dict(bindings)
+ pdk_binding = _string_keyed_dict(binding_map.get("pdk"))
+ root = pdk_binding.get("root")
+ if not isinstance(root, str) or not Path(root).is_dir():
+ return {"ready": False, "code": "pdk_binding_missing"}
+ try:
+ get_pdk(workspace.pdk.name, pdk_root=root).validate()
+ except (OSError, ValueError):
+ return {"ready": False, "code": "pdk_binding_mismatch"}
+ return {"ready": True}
+
+
+def apply_workspace_bindings(workspace, bindings: object) -> None:
+ binding_map = _string_keyed_dict(bindings)
+ pdk_binding = _string_keyed_dict(binding_map.get("pdk"))
+ root = pdk_binding.get("root")
+ if not isinstance(root, str) or not Path(root).is_dir():
+ raise WorkspaceLifecycleError("pdk_binding_missing", "PDK binding root is required")
+ sdc, spef = workspace.pdk.sdc, workspace.pdk.spef
+ workspace.pdk = get_pdk(workspace.pdk.name, pdk_root=root)
+ workspace.pdk.sdc, workspace.pdk.spef = sdc, spef
+ from chipcompiler.data import refresh_workspace_config
+
+ refresh_workspace_config(workspace)
+
+
def create_workspace_from_spec(
target_directory: str | Path,
spec: object,
diff --git a/test/engine/test_execution.py b/test/engine/test_execution.py
new file mode 100644
index 00000000..ba0851f0
--- /dev/null
+++ b/test/engine/test_execution.py
@@ -0,0 +1,50 @@
+from types import SimpleNamespace
+
+from chipcompiler.data import StateEnum
+from chipcompiler.engine.execution import ExecutionPlan, execute
+
+
+def test_execution_plan_dispatches_full_flow_and_single_step():
+ calls = []
+
+ class Flow:
+ def run_steps(self, *, rerun=False, observer=None):
+ calls.append(("flow", rerun, observer))
+ return True
+
+ def get_workspace_step(self, step_id):
+ return SimpleNamespace(name=step_id)
+
+ def run_step(self, step, *, rerun=False, observer=None):
+ calls.append((step.name, rerun, observer))
+ return StateEnum.Success
+
+ observer = object()
+ flow = Flow()
+
+ assert execute(flow, ExecutionPlan(intent="run"), event_sink=observer).succeeded
+ step_result = execute(
+ flow,
+ ExecutionPlan(intent="rerun", step_id="Floorplan"),
+ event_sink=observer,
+ )
+
+ assert step_result == step_result.__class__(
+ succeeded=True,
+ state=StateEnum.Success.value,
+ step_id="Floorplan",
+ )
+ assert calls == [("flow", False, observer), ("Floorplan", True, observer)]
+
+
+def test_execution_failure_keeps_main_blocking_semantics():
+ class Flow:
+ workspace = SimpleNamespace(flow=SimpleNamespace(data={"steps": []}))
+
+ def run_steps(self, *, rerun=False, observer=None):
+ return False
+
+ result = execute(Flow(), ExecutionPlan(intent="run"), event_sink=object())
+
+ assert not result.succeeded
+ assert result.state == StateEnum.Imcomplete.value
diff --git a/test/engine/test_workspace_flow.py b/test/engine/test_workspace_flow.py
new file mode 100644
index 00000000..06d54bf7
--- /dev/null
+++ b/test/engine/test_workspace_flow.py
@@ -0,0 +1,14 @@
+from chipcompiler.engine.workspace_flow import build_flow_for_workspace
+
+
+def test_build_flow_requires_committed_flow(tmp_path):
+ from chipcompiler.data import Workspace
+
+ workspace = Workspace(directory=tmp_path)
+
+ try:
+ build_flow_for_workspace(workspace, create_step_workspaces=False)
+ except ValueError as error:
+ assert str(error) == "Workspace has no committed Flow"
+ else:
+ raise AssertionError("missing committed Flow was accepted")
From 85cb3a6c854c277ff575da258131a0298fedcd04 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 01:00:39 +0800
Subject: [PATCH 06/25] feat(engine): expose analysis and signoff facts
---
chipcompiler/engine/analysis.py | 431 ++++++++++++++++++++++
chipcompiler/engine/qor.py | 123 ++++++
chipcompiler/engine/qor_scoring.py | 163 ++++++++
chipcompiler/engine/signoff_assessment.py | 174 +++++++++
chipcompiler/engine/signoff_export.py | 72 ++++
chipcompiler/engine/snapshot.py | 16 +-
test/engine/test_qor_scoring.py | 34 ++
7 files changed, 1008 insertions(+), 5 deletions(-)
create mode 100644 chipcompiler/engine/analysis.py
create mode 100644 chipcompiler/engine/qor.py
create mode 100644 chipcompiler/engine/qor_scoring.py
create mode 100644 chipcompiler/engine/signoff_assessment.py
create mode 100644 chipcompiler/engine/signoff_export.py
create mode 100644 test/engine/test_qor_scoring.py
diff --git a/chipcompiler/engine/analysis.py b/chipcompiler/engine/analysis.py
new file mode 100644
index 00000000..2e1c58c2
--- /dev/null
+++ b/chipcompiler/engine/analysis.py
@@ -0,0 +1,431 @@
+import hashlib
+import math
+from pathlib import Path
+from typing import Any, TypeGuard
+
+from chipcompiler.data.step_dirs import STEP_DIRECTORIES
+from chipcompiler.tools.ecc.sta_qor import STA_POWER_REPORT_FILENAME, STA_REPORT_FILENAMES
+from chipcompiler.utility import JsonReadError, file_digest, json_read_strict
+
+_ANALYSIS_FILES = (
+ ("metrics", "qor_metrics", "qor_metrics.json", 3),
+ ("summary", "qor_summary", "qor_summary.json", 4),
+ ("hotspots", "qor_hotspots", "qor_hotspots.json", 3),
+)
+_TIMING_FILE = ("timingIssues", "sta_timing_issues", "sta_timing_issues.json", 1)
+_SUBFLOW_MAX_BYTES = 1024 * 1024
+_SUBFLOW_MAX_STEPS = 256
+_ARTIFACT_HASH_MAX_BYTES = 16 * 1024 * 1024
+_CONGESTION_IMAGES = (
+ ("egr_congestion_map", "{step}_egr_horizontal_overflow.png"),
+ ("egr_congestion_map", "{step}_egr_vertical_overflow.png"),
+ ("egr_congestion_map", "{step}_egr_union_overflow.png"),
+ ("RUDY_map", "{step}_rudy_horizontal.png"),
+ ("RUDY_map", "{step}_rudy_vertical.png"),
+ ("RUDY_map", "{step}_rudy_union.png"),
+ ("RUDY_map", "{step}_lut_rudy_horizontal.png"),
+ ("RUDY_map", "{step}_lut_rudy_vertical.png"),
+ ("RUDY_map", "{step}_lut_rudy_union.png"),
+ ("density_map", "{step}_allcell_density.png"),
+)
+
+
+def build_workspace_analysis(
+ workspace: Any, workspace_id: str
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ root = Path(workspace.directory).resolve()
+ flow = getattr(getattr(workspace, "flow", None), "data", {})
+ raw_steps = flow.get("steps", []) if isinstance(flow, dict) else []
+ design = str(getattr(getattr(workspace, "design", None), "name", "")).strip()
+ steps: list[dict[str, Any]] = []
+ artifacts: list[dict[str, Any]] = []
+ for order, raw_step in enumerate(raw_steps):
+ if not isinstance(raw_step, dict):
+ continue
+ step_id = raw_step.get("name")
+ tool_id = raw_step.get("tool")
+ if not _safe_segment(step_id) or not _safe_segment(tool_id):
+ continue
+ identity = "".join(character for character in step_id.casefold() if character.isalnum())
+ if identity == "fixfanout":
+ continue
+ step_dir = root / STEP_DIRECTORIES.get(step_id, f"{step_id}_{tool_id}")
+ step: dict[str, Any] = {
+ "stepId": step_id,
+ "toolId": tool_id,
+ "order": order,
+ "flowState": str(raw_step.get("state", "")),
+ }
+ declared = list(_ANALYSIS_FILES)
+ if step_id.lower() == "sta":
+ declared.append(_TIMING_FILE)
+ for field, kind, filename, schema_version in declared:
+ path = step_dir / "analysis" / filename
+ reference = path.relative_to(root).as_posix()
+ artifact = _artifact_ref(
+ path,
+ workspace_id=workspace_id,
+ reference=reference,
+ step_id=step_id,
+ kind=kind,
+ root=root,
+ )
+ artifacts.append(artifact)
+ step[field] = _analysis_file(path, artifact["artifactId"], schema_version, root)
+ if "timingIssues" not in step:
+ step["timingIssues"] = None
+ if tool_id.lower() == "yosys_lec" and design:
+ result = step_dir / "output" / f"{design}_{step_id}_result.json"
+ artifact = _artifact_ref(
+ result,
+ workspace_id=workspace_id,
+ reference=result.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="lec_result",
+ root=root,
+ )
+ artifacts.append(artifact)
+ step["lecResult"] = _lec_result_file(result, artifact["artifactId"], root)
+ step["subflow"] = _subflow_summary(step_dir / "subflow.json", root)
+ if design:
+ layout = step_dir / "output" / f"{design}_{step_id}.png"
+ artifacts.append(
+ _artifact_ref(
+ layout,
+ workspace_id=workspace_id,
+ reference=layout.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="layout_image",
+ root=root,
+ )
+ )
+ if step_id.lower() == "harden":
+ for suffix in ("gds", "lef", "lib"):
+ output = step_dir / "output" / f"{design}_{step_id}.{suffix}"
+ artifacts.append(
+ _artifact_ref(
+ output,
+ workspace_id=workspace_id,
+ reference=output.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="harden_output",
+ root=root,
+ )
+ )
+ geometry = step_dir / "output" / "geometry" / "geometry.manifest"
+ artifacts.append(
+ _artifact_ref(
+ geometry,
+ workspace_id=workspace_id,
+ reference=geometry.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="layout_geometry",
+ root=root,
+ )
+ )
+ report_names = (
+ (f"{step_id}_stat.json", f"{step_id}_check.rpt")
+ if tool_id.lower() == "yosys"
+ else (f"{step_id}.db.rpt", f"{step_id}.rpt")
+ )
+ for report_name in report_names:
+ report = step_dir / "report" / report_name
+ artifacts.append(
+ _artifact_ref(
+ report,
+ workspace_id=workspace_id,
+ reference=report.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="report_text",
+ root=root,
+ )
+ )
+ timing_files: list[tuple[str, Path]] = []
+ if step_id.lower() == "synthesis":
+ timing_root = step_dir / "feature" / "post_synthesis"
+ timing_files.extend(
+ (
+ ("timing_summary", timing_root / "qor_summary.json"),
+ ("timing_paths", timing_root / "timing_paths.json"),
+ )
+ )
+ timing_data = step.get("timingIssues")
+ timing_payload = timing_data.get("data") if isinstance(timing_data, dict) else None
+ timing_paths = (
+ timing_payload.get("artifact_paths") if isinstance(timing_payload, dict) else None
+ )
+ if step_id.lower() == "sta":
+ for item in timing_paths[:32] if isinstance(timing_paths, list) else []:
+ if not isinstance(item, dict):
+ continue
+ report_dir = _safe_step_artifact_path(step_dir, item.get("report_dir"), root)
+ if report_dir is None:
+ continue
+ try:
+ relative_report_dir = report_dir.relative_to(step_dir / "report")
+ except ValueError:
+ continue
+ report_names = list(STA_REPORT_FILENAMES)
+ if (report_dir / STA_POWER_REPORT_FILENAME).is_file():
+ report_names.append(STA_POWER_REPORT_FILENAME)
+ for report_name in report_names:
+ report = report_dir / report_name
+ artifact = _artifact_ref(
+ report,
+ workspace_id=workspace_id,
+ reference=report.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="report_text",
+ root=root,
+ )
+ artifact["name"] = (relative_report_dir / report_name).as_posix()
+ artifacts.append(artifact)
+ for item in timing_paths[:32] if isinstance(timing_paths, list) else []:
+ if not isinstance(item, dict):
+ continue
+ for kind, field in (
+ ("timing_summary", "qor_summary_file"),
+ ("timing_paths", "timing_paths_file"),
+ ):
+ timing_path = _safe_step_artifact_path(step_dir, item.get(field), root)
+ if timing_path is not None:
+ timing_files.append((kind, timing_path))
+ for kind, path in timing_files:
+ artifacts.append(
+ _artifact_ref(
+ path,
+ workspace_id=workspace_id,
+ reference=path.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind=kind,
+ root=root,
+ )
+ )
+ if step_id.lower() in {"place", "cts"}:
+ for directory, filename in _CONGESTION_IMAGES:
+ image = step_dir / "feature" / directory / filename.format(step=step_id)
+ artifacts.append(
+ _artifact_ref(
+ image,
+ workspace_id=workspace_id,
+ reference=image.relative_to(root).as_posix(),
+ step_id=step_id,
+ kind="congestion_image",
+ root=root,
+ )
+ )
+ steps.append(step)
+ return {"steps": steps}, artifacts
+
+
+def _lec_result_file(path: Path, artifact_id: str, root: Path) -> dict[str, Any]:
+ if _has_symlink(path, root):
+ return {
+ "artifactId": artifact_id,
+ "status": "unsafe",
+ "reasonCode": "LEC_RESULT_UNSAFE",
+ "data": None,
+ }
+ if not path.is_file():
+ return {
+ "artifactId": artifact_id,
+ "status": "missing",
+ "reasonCode": "LEC_RESULT_MISSING",
+ "data": None,
+ }
+ try:
+ data = json_read_strict(path)
+ except (OSError, JsonReadError):
+ return {
+ "artifactId": artifact_id,
+ "status": "invalid",
+ "reasonCode": "LEC_RESULT_INVALID",
+ "data": None,
+ }
+ if not isinstance(data, dict):
+ return {
+ "artifactId": artifact_id,
+ "status": "invalid",
+ "reasonCode": "LEC_RESULT_INVALID",
+ "data": None,
+ }
+ result = dict(data)
+ result["freshness_status"] = _lec_freshness_status(data, root)
+ return {"artifactId": artifact_id, "status": "available", "data": result}
+
+
+def _lec_freshness_status(data: dict[str, Any], root: Path) -> str:
+ if data.get("status") != "proven":
+ return "incomplete"
+ for role in ("golden", "gate"):
+ path = data.get(f"{role}_verilog")
+ digest = data.get(f"{role}_sha256")
+ size = data.get(f"{role}_size_bytes")
+ if not isinstance(path, str) or not isinstance(digest, str) or type(size) is not int:
+ return "stale"
+ candidate = Path(path).resolve()
+ if not candidate.is_relative_to(root) or not candidate.is_file():
+ return "stale"
+ actual = file_digest(candidate)
+ if actual is None or actual != (digest, size):
+ return "stale"
+ return "proven"
+
+
+def _analysis_file(path: Path, artifact_id: str, schema_version: int, root: Path) -> dict[str, Any]:
+ if _has_symlink(path, root):
+ return {
+ "artifactId": artifact_id,
+ "status": "unsafe",
+ "reasonCode": "ANALYSIS_REFERENCE_UNSAFE",
+ "data": None,
+ }
+ if not path.is_file():
+ return {
+ "artifactId": artifact_id,
+ "status": "missing",
+ "reasonCode": "ANALYSIS_FILE_MISSING",
+ "data": None,
+ }
+ try:
+ data = json_read_strict(path)
+ except (OSError, JsonReadError):
+ return {
+ "artifactId": artifact_id,
+ "status": "invalid",
+ "reasonCode": "ANALYSIS_FILE_INVALID",
+ "data": None,
+ }
+ if not isinstance(data, dict):
+ return {
+ "artifactId": artifact_id,
+ "status": "invalid",
+ "reasonCode": "ANALYSIS_FILE_INVALID",
+ "data": None,
+ }
+ if data.get("schema_version") != schema_version:
+ return {
+ "artifactId": artifact_id,
+ "status": "unsupported",
+ "reasonCode": "ANALYSIS_SCHEMA_UNSUPPORTED",
+ "data": None,
+ }
+ return {"artifactId": artifact_id, "status": "available", "data": data}
+
+
+def _subflow_summary(path: Path, root: Path) -> dict[str, Any]:
+ if _has_symlink(path, root):
+ return {"status": "unsafe", "steps": []}
+ if not path.is_file():
+ return {"status": "missing", "steps": []}
+ try:
+ if path.stat().st_size > _SUBFLOW_MAX_BYTES:
+ return {"status": "oversized", "steps": []}
+ data = json_read_strict(path)
+ except (OSError, JsonReadError):
+ return {"status": "invalid", "steps": []}
+ raw_steps = data.get("steps") if isinstance(data, dict) else None
+ if not isinstance(raw_steps, list) or len(raw_steps) > _SUBFLOW_MAX_STEPS:
+ return {"status": "invalid", "steps": []}
+ steps: list[dict[str, Any]] = []
+ for item in raw_steps:
+ if not isinstance(item, dict):
+ return {"status": "invalid", "steps": []}
+ name = item.get("name")
+ state = item.get("state")
+ if not isinstance(name, str) or not name or not isinstance(state, str):
+ return {"status": "invalid", "steps": []}
+ runtime = item.get("runtime")
+ peak_memory = item.get("peak memory (mb)")
+ step: dict[str, Any] = {"name": name, "state": state}
+ if isinstance(runtime, str):
+ step["runtime"] = runtime
+ if (
+ isinstance(peak_memory, (int, float))
+ and not isinstance(peak_memory, bool)
+ and math.isfinite(peak_memory)
+ ):
+ step["peakMemoryMb"] = peak_memory
+ steps.append(step)
+ return {"status": "available", "steps": steps}
+
+
+def _artifact_ref(
+ path: Path,
+ *,
+ workspace_id: str,
+ reference: str,
+ step_id: str,
+ kind: str,
+ root: Path,
+) -> dict[str, Any]:
+ artifact: dict[str, Any] = {
+ "artifactId": _artifact_id(workspace_id, reference),
+ "kind": kind,
+ "name": path.name,
+ "stepId": step_id,
+ "availability": "missing",
+ "reference": reference,
+ }
+ try:
+ if path.is_file() and not _has_symlink(path, root):
+ size = path.stat().st_size
+ if size <= _ARTIFACT_HASH_MAX_BYTES:
+ artifact.update(
+ availability="available",
+ sizeBytes=size,
+ sha256=_sha256(path),
+ )
+ else:
+ artifact.update(availability="stale", sizeBytes=size)
+ except OSError:
+ pass
+ return artifact
+
+
+def _safe_step_artifact_path(step_dir: Path, value: object, root: Path) -> Path | None:
+ if not isinstance(value, str) or not value:
+ return None
+ candidate = Path(value)
+ if candidate.is_absolute() or ".." in candidate.parts:
+ return None
+ resolved = step_dir / candidate
+ try:
+ resolved.relative_to(root)
+ except ValueError:
+ return None
+ return resolved
+
+
+def _safe_segment(value: object) -> TypeGuard[str]:
+ return (
+ isinstance(value, str)
+ and bool(value)
+ and value not in {".", ".."}
+ and "/" not in value
+ and "\\" not in value
+ )
+
+
+def _has_symlink(path: Path, root: Path) -> bool:
+ current = path
+ while current != root:
+ if current.is_symlink():
+ return True
+ if current.parent == current:
+ return True
+ current = current.parent
+ return False
+
+
+def _artifact_id(workspace_id: str, reference: str) -> str:
+ digest = hashlib.sha256(f"{workspace_id}\0{reference}".encode()).hexdigest()
+ return f"artifact-{digest[:32]}"
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as artifact:
+ for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
diff --git a/chipcompiler/engine/qor.py b/chipcompiler/engine/qor.py
new file mode 100644
index 00000000..6cef28c2
--- /dev/null
+++ b/chipcompiler/engine/qor.py
@@ -0,0 +1,123 @@
+import math
+from typing import Any
+
+from .qor_scoring import (
+ DIMENSION_WEIGHTS,
+ QOR_SCORE_THRESHOLD,
+ QorScoringMetric,
+ score_qor,
+)
+
+
+def build_workspace_qor_assessment(analysis: dict[str, Any]) -> dict[str, Any]:
+ metrics = []
+ step_summaries = []
+ metric_steps = []
+ for step in analysis["steps"]:
+ if step["flowState"] != "Success":
+ continue
+ step_id = step["stepId"]
+ metrics_file = step["metrics"]
+ payload = metrics_file["data"] if metrics_file["status"] == "available" else {}
+ records = payload.get("metrics")
+ valid_records = [record for record in records or [] if _valid_metric(record)]
+ metrics.extend(valid_records)
+ metric_steps.extend((step_id, record) for record in valid_records)
+ summary_file = step["summary"]
+ summary = summary_file["data"] if summary_file["status"] == "available" else {}
+ summary_status = (
+ str(summary.get("quality_status", "incomplete"))
+ if summary.get("schema_version") == 4
+ else "unavailable"
+ )
+ step_summaries.append(
+ {
+ "stepId": step_id,
+ "order": step["order"],
+ "name": step_id,
+ "status": summary_status,
+ "summaryMetricCount": len(valid_records),
+ }
+ )
+
+ if not metrics:
+ return {
+ "status": "unavailable",
+ "score": {"value": None, "threshold": QOR_SCORE_THRESHOLD, "gate": "unavailable"},
+ "areaScoringStep": None,
+ "dimensionScores": {},
+ "metrics": [],
+ "steps": step_summaries,
+ }
+
+ gate = _gate_status(step_summaries)
+ scoring = score_qor(
+ [
+ QorScoringMetric(
+ step=step_id,
+ metric_id=record["id"],
+ value=float(record["value"]),
+ dimension=record["category"],
+ direction=record["direction"],
+ scope=record["scope"],
+ corner=record.get("corner"),
+ project_role=record["project_role"],
+ rating_score=record["rating"]["score"],
+ )
+ for step_id, record in metric_steps
+ ]
+ )
+ return {
+ "status": "ready",
+ "score": {
+ "value": scoring.overall_score,
+ "threshold": QOR_SCORE_THRESHOLD,
+ "gate": gate,
+ },
+ "areaScoringStep": scoring.area_scoring_step,
+ "dimensionScores": {
+ dimension: score for dimension, (score, _count) in scoring.dimensions.items()
+ },
+ "metrics": metrics,
+ "steps": step_summaries,
+ }
+
+
+def _valid_metric(record: Any) -> bool:
+ if not isinstance(record, dict):
+ return False
+ value = record.get("value")
+ rating = record.get("rating")
+ corner = record.get("corner")
+ corner_context = record.get("corner_context")
+ return (
+ isinstance(record.get("id"), str)
+ and isinstance(record.get("display_name"), str)
+ and isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and math.isfinite(value)
+ and record.get("category") in DIMENSION_WEIGHTS
+ and record.get("direction")
+ in {"higher_is_better", "lower_is_better", "target_range", "trend_only"}
+ and isinstance(record.get("scope"), str)
+ and (corner is None or isinstance(corner, str))
+ and (corner_context is None or isinstance(corner_context, dict))
+ and isinstance(record.get("analysis_group"), str)
+ and isinstance(rating, dict)
+ and isinstance(rating.get("gate"), bool)
+ and isinstance(rating.get("score"), bool)
+ and isinstance(rating.get("trend"), bool)
+ and record.get("project_role") in {"final", "trend", "gate", "none"}
+ and record.get("step_role") in {"primary", "secondary", "detail", "hidden"}
+ and record.get("confidence") in {"high", "medium", "low"}
+ and isinstance(record.get("source"), dict)
+ )
+
+
+def _gate_status(steps: list[dict[str, Any]]) -> str:
+ statuses = {step["status"] for step in steps}
+ if "blocked" in statuses:
+ return "blocked"
+ if statuses & {"incomplete", "unavailable"}:
+ return "incomplete"
+ return "pass"
diff --git a/chipcompiler/engine/qor_scoring.py b/chipcompiler/engine/qor_scoring.py
new file mode 100644
index 00000000..00fc6283
--- /dev/null
+++ b/chipcompiler/engine/qor_scoring.py
@@ -0,0 +1,163 @@
+from dataclasses import dataclass
+from typing import Any
+
+QOR_SCORE_THRESHOLD = 60
+
+DIMENSION_WEIGHTS = {
+ "timing": 0.35,
+ "power_integrity": 0.25,
+ "routability_physical": 0.2,
+ "area_cost": 0.1,
+ "clock_robustness_dfm": 0.1,
+ "runtime": 0.0,
+}
+
+METRIC_FAIL_VALUES = {
+ "drc_count": 10,
+ "lvs_count": 10,
+ "route_wirelength": 6000,
+ "route_via_count": 2000,
+ "cts_buffer_count": 20,
+ "cts_buffer_area": 40,
+ "clock_wirelength": 400000,
+ "cts_clock_wirelength_max": 100000,
+ "cts_clock_tree_max_level": 20,
+ "die_area": 3000,
+ "core_area": 2500,
+ "core_utilization": 0.85,
+ "synthesis_cell_area": 3000,
+ "fanout_max": 100,
+ "place_hpwl": 10000,
+ "place_grwl": 12000,
+ "place_flute_wirelength": 10000,
+ "place_congestion_egr_overflow_total": 100,
+ "place_congestion_egr_overflow_max": 20,
+ "place_rudy_utilization_max": 1,
+ "place_lutrudy_utilization_max": 1,
+ "route_dr_total_violation_count": 50,
+ "route_dr_total_patch_count": 100,
+ "route_dr_total_wirelength": 6000,
+ "route_dr_total_via_count": 2000,
+ "route_la_total_overflow": 100,
+ "rcx_missing_corner_count": 9,
+ "sta_setup_wns": -0.2,
+ "sta_setup_tns": -1,
+ "sta_hold_wns": -0.2,
+ "sta_hold_tns": -1,
+ "sta_frequency_mhz": 100,
+ "sta_setup_violation_count": 1,
+ "sta_hold_violation_count": 1,
+ "sta_missing_corner_count": 1,
+ "harden_artifact_missing_count": 6,
+}
+
+_SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"}
+_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3}
+
+
+@dataclass(frozen=True)
+class QorScoringMetric:
+ step: str
+ metric_id: str
+ value: float
+ dimension: str
+ direction: str
+ scope: str
+ corner: str | None
+ project_role: str
+ rating_score: bool
+ payload: Any = None
+
+
+@dataclass(frozen=True)
+class ScoredQorMetric:
+ metric: QorScoringMetric
+ score: float | None
+
+
+@dataclass(frozen=True)
+class QorScoringResult:
+ area_scoring_step: str | None
+ metrics: tuple[ScoredQorMetric, ...]
+ dimensions: dict[str, tuple[float, int]]
+ overall_score: float | None
+
+
+def score_qor(records: list[QorScoringMetric]) -> QorScoringResult:
+ area_step = next(
+ (
+ record.step
+ for record in reversed(records)
+ if record.dimension == "area_cost" and record.rating_score
+ ),
+ None,
+ )
+ selected: dict[tuple[str, str, str], tuple[int, int, QorScoringMetric]] = {}
+ for order, record in enumerate(records):
+ if record.project_role == "none":
+ continue
+ if record.dimension == "area_cost" and record.step != area_step:
+ continue
+ key = (record.metric_id, record.scope, record.corner or "")
+ candidate = (_ROLE_PRIORITY.get(record.project_role, 3), -order, record)
+ if key not in selected or candidate[:2] < selected[key][:2]:
+ selected[key] = candidate
+
+ scored = tuple(
+ ScoredQorMetric(record, score_metric(record) if record.rating_score else None)
+ for _role, _order, record in sorted(selected.values(), key=lambda item: item[2].metric_id)
+ )
+ by_dimension: dict[str, list[float]] = {}
+ for item in scored:
+ if item.score is not None:
+ by_dimension.setdefault(item.metric.dimension, []).append(item.score)
+ dimensions = {
+ dimension: (
+ round(sum(by_dimension[dimension]) / len(by_dimension[dimension]), 1),
+ len(by_dimension[dimension]),
+ )
+ for dimension in DIMENSION_WEIGHTS
+ if dimension in by_dimension
+ }
+ weighted = sum(
+ score * DIMENSION_WEIGHTS[dimension]
+ for dimension, (score, _count) in dimensions.items()
+ if DIMENSION_WEIGHTS[dimension] > 0
+ )
+ overall = (
+ round(weighted, 1)
+ if any(DIMENSION_WEIGHTS[dimension] > 0 for dimension in dimensions)
+ else None
+ )
+ return QorScoringResult(area_step, scored, dimensions, overall)
+
+
+def score_metric(record: QorScoringMetric) -> float | None:
+ if record.direction == "trend_only":
+ return None
+ fail = METRIC_FAIL_VALUES.get(record.metric_id)
+ if fail is None:
+ return None
+ if record.metric_id in _SLACK_METRICS:
+ if fail >= 0:
+ return None
+ return 100.0 if record.value >= 0 else _clamp(100 * (record.value - fail) / -fail)
+ if record.direction == "target_range":
+ if record.metric_id != "core_utilization":
+ return None
+ if 0.45 <= record.value <= 0.7:
+ return 100.0
+ if record.value < 0.45:
+ return _clamp(100 * record.value / 0.45)
+ return _clamp(100 * (fail - record.value) / (fail - 0.7))
+ if fail <= 0:
+ return None
+ if record.direction == "lower_is_better":
+ return _clamp(100 * (fail - record.value) / fail)
+ if record.direction == "higher_is_better":
+ return _clamp(100 * record.value / fail)
+ return None
+
+
+def _clamp(value: float) -> float:
+ return max(0.0, min(100.0, value))
diff --git a/chipcompiler/engine/signoff_assessment.py b/chipcompiler/engine/signoff_assessment.py
new file mode 100644
index 00000000..9b378ce2
--- /dev/null
+++ b/chipcompiler/engine/signoff_assessment.py
@@ -0,0 +1,174 @@
+from pathlib import Path
+from typing import Any, TypedDict
+
+from chipcompiler.utility import json_read
+
+_REVIEW_GROUPS: tuple[tuple[str, str], ...] = (
+ ("initial", "Initial"),
+ ("config", "Config"),
+ ("harden", "Harden"),
+ ("final_design", "Final Design"),
+ ("sta", "STA"),
+ ("spef", "SPEF"),
+ ("reports", "Reports"),
+)
+
+
+class _ReviewGroup(TypedDict):
+ id: str
+ label: str
+ available: int
+ expected: int
+ blocked: list[dict]
+ attention: list[dict]
+
+
+def build_signoff_assessment(workspace: Any) -> dict[str, Any]:
+ checklist = json_read(Path(workspace.directory) / "home" / "checklist.json")
+ if checklist.get("schema_version") != 3 or checklist.get("kind") != "signoff_checklist":
+ return _unavailable_assessment()
+
+ groups: dict[str, _ReviewGroup] = {}
+ for group_id, label in _REVIEW_GROUPS:
+ groups[group_id] = {
+ "id": group_id,
+ "label": label,
+ "available": 0,
+ "expected": 0,
+ "blocked": [],
+ "attention": [],
+ }
+ for item in checklist.get("checklist", []):
+ if not isinstance(item, dict):
+ continue
+ group = groups[_group_for(item)]
+ group["expected"] += 1
+ if item.get("state") == "pass":
+ group["available"] += 1
+ elif item.get("blocked") is True:
+ group["blocked"].append(_detail(item))
+ else:
+ group["attention"].append(_detail(item))
+
+ review_groups = []
+ risks = []
+ for group_id, _label in _REVIEW_GROUPS:
+ group = groups[group_id]
+ blocked = group["blocked"]
+ attention = group["attention"]
+ if blocked:
+ status = "blocked"
+ summary = f"{len(blocked)} blocking checklist requirements"
+ risks.append(_risk(group, "blocked", blocked, summary))
+ if attention:
+ risks.append(
+ _risk(
+ group,
+ "warning",
+ attention,
+ f"{len(attention)} attention-only checklist requirements",
+ )
+ )
+ elif attention:
+ status = "attention"
+ summary = f"{len(attention)} attention-only checklist requirements"
+ risks.append(_risk(group, "warning", attention, summary))
+ else:
+ status = "ready"
+ summary = (
+ f"{group['available']} of {group['expected']} requirements ready"
+ if group["expected"]
+ else "No requirements"
+ )
+ review_groups.append(
+ {
+ "id": group_id,
+ "label": group["label"],
+ "status": status,
+ "available": group["available"],
+ "expected": group["expected"],
+ "summary": summary,
+ }
+ )
+
+ status = checklist.get("status")
+ return {
+ "status": status if status in {"ready", "attention", "blocked"} else "blocked",
+ "groups": review_groups,
+ "risks": sorted(risks, key=lambda risk: risk["severity"] != "blocked"),
+ }
+
+
+def _risk(group: _ReviewGroup, severity: str, details: list[dict], summary: str) -> dict:
+ result = "requirements block export" if severity == "blocked" else "attention"
+ return {
+ "severity": severity,
+ "title": f"{group['label']} signoff {result}",
+ "summary": summary,
+ "details": details,
+ }
+
+
+def _detail(item: dict) -> dict:
+ source = item.get("source", {})
+ source = source if isinstance(source, dict) else {}
+ evidence = item.get("evidence", [])
+ return {
+ "kind": item.get("category", "checklist"),
+ "label": item.get("title", "Checklist item"),
+ "location": source.get("path", "home/checklist.json"),
+ "reason": item.get("summary", ""),
+ "owner": item.get("owner", "checklist"),
+ "policy": item.get("policy", "warn"),
+ "state": item.get("state", "unavailable"),
+ "evidence": evidence if isinstance(evidence, list) else [],
+ }
+
+
+def _group_for(item: dict) -> str:
+ step = str(item.get("step", ""))
+ category = str(item.get("category", ""))
+ source = item.get("source", {})
+ path = source.get("path", "") if isinstance(source, dict) else ""
+ if category == "configuration" or path.startswith("config/"):
+ return "config"
+ if category == "provenance" or path.startswith(("origin/", "initial/")):
+ return "initial"
+ if step == "Harden" or path.startswith(("Harden_ecc/", "harden/")):
+ return "harden"
+ if step == "sta" or path.startswith(("sta_ecc/", "final/timing/sta/")):
+ return "sta"
+ if step == "RCX" or path.startswith(("RCX_ecc/", "final/timing/spef/")):
+ return "spef"
+ if step in {"Route", "drc", "lvs", "filler"} or path.startswith(
+ ("route_ecc/", "drc_ecc/", "lvs_ecc/", "filler_ecc/", "final/design/")
+ ):
+ return "final_design"
+ return "reports"
+
+
+def _unavailable_assessment() -> dict[str, Any]:
+ return {
+ "status": "blocked",
+ "groups": [
+ {
+ "id": group_id,
+ "label": label,
+ "status": "blocked" if group_id == "reports" else "ready",
+ "available": 0,
+ "expected": 0,
+ "summary": (
+ "Checklist unavailable" if group_id == "reports" else "No requirements"
+ ),
+ }
+ for group_id, label in _REVIEW_GROUPS
+ ],
+ "risks": [
+ {
+ "severity": "blocked",
+ "title": "Signoff checklist unavailable",
+ "summary": "Re-run signoff inspection after current-output analysis completes.",
+ "details": [],
+ }
+ ],
+ }
diff --git a/chipcompiler/engine/signoff_export.py b/chipcompiler/engine/signoff_export.py
new file mode 100644
index 00000000..1c7a19d9
--- /dev/null
+++ b/chipcompiler/engine/signoff_export.py
@@ -0,0 +1,72 @@
+import os
+import shutil
+import tempfile
+from pathlib import Path
+
+from chipcompiler.engine import EngineFlow, SignoffPackageOptions
+from chipcompiler.engine.signoff_assessment import build_signoff_assessment
+
+
+class SignoffExportError(RuntimeError):
+ pass
+
+
+def inspect_signoff_package(workspace) -> dict:
+ """Return the current Signoff Assessment without mutating the Workspace."""
+ return build_signoff_assessment(workspace)
+
+
+def export_signoff_package_archive(
+ workspace,
+ output_path: str,
+ additional_files: list[dict[str, str]] | None = None,
+ *,
+ include_debug: bool = False,
+) -> str:
+ raw_destination = Path(output_path).expanduser()
+ destination = raw_destination.parent.resolve() / raw_destination.name
+
+ with tempfile.TemporaryDirectory(prefix="ecc-signoff-") as temporary_root:
+ result = EngineFlow(workspace).collect_signoff_package(
+ SignoffPackageOptions(
+ output_dir=temporary_root,
+ archive=False,
+ include_debug=include_debug,
+ materialize=False,
+ refresh_analysis=False,
+ )
+ )
+ if not result.ok:
+ missing = ", ".join(result.missing_required) or "unknown required resources"
+ raise SignoffExportError(f"signoff package is incomplete: {missing}")
+ if not result.package_dir:
+ raise SignoffExportError("signoff package directory was not created")
+
+ package_dir = Path(result.package_dir)
+
+ if additional_files:
+ for file_info in additional_files:
+ p = package_dir / file_info["archivePath"]
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(file_info["content"], encoding="utf-8")
+
+ archive = package_dir.with_suffix(".tar.gz")
+ import tarfile
+
+ with tarfile.open(archive, "w:gz") as tar:
+ tar.add(package_dir, arcname=package_dir.name)
+
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, staged_name = tempfile.mkstemp(
+ dir=destination.parent,
+ prefix=f".{destination.name}.",
+ )
+ os.close(descriptor)
+ staged_path = Path(staged_name)
+ try:
+ shutil.copy2(archive, staged_path)
+ os.replace(staged_path, destination)
+ finally:
+ staged_path.unlink(missing_ok=True)
+
+ return str(destination)
diff --git a/chipcompiler/engine/snapshot.py b/chipcompiler/engine/snapshot.py
index 34a6fa6d..86984912 100644
--- a/chipcompiler/engine/snapshot.py
+++ b/chipcompiler/engine/snapshot.py
@@ -154,6 +154,12 @@ def _build_snapshot(
home = _data_mapping(getattr(workspace, "home", None))
checklist_path = home.get("checklist")
checklist = json_read(checklist_path) if isinstance(checklist_path, (str, Path)) else {}
+ from chipcompiler.engine.analysis import build_workspace_analysis
+ from chipcompiler.engine.qor import build_workspace_qor_assessment
+ from chipcompiler.engine.signoff_assessment import build_signoff_assessment
+
+ analysis, artifacts = build_workspace_analysis(workspace, workspace_id)
+ qor_assessment = build_workspace_qor_assessment(analysis)
return {
"schemaVersion": SNAPSHOT_SCHEMA_VERSION,
"workspaceId": workspace_id,
@@ -162,11 +168,11 @@ def _build_snapshot(
"flow": flow,
"parameters": _data_mapping(getattr(workspace, "parameters", None)),
"checklist": checklist if isinstance(checklist, dict) else {},
- "analysis": {"steps": []},
- "metrics": {},
- "qorAssessment": {"status": "unavailable", "metrics": {}, "dimensions": {}},
- "signoffAssessment": {"status": "blocked", "groups": [], "risks": []},
- "artifacts": [],
+ "analysis": analysis,
+ "metrics": deepcopy(qor_assessment["metrics"]),
+ "qorAssessment": qor_assessment,
+ "signoffAssessment": build_signoff_assessment(workspace),
+ "artifacts": artifacts,
}
diff --git a/test/engine/test_qor_scoring.py b/test/engine/test_qor_scoring.py
new file mode 100644
index 00000000..d61317db
--- /dev/null
+++ b/test/engine/test_qor_scoring.py
@@ -0,0 +1,34 @@
+from chipcompiler.engine.qor_scoring import QorScoringMetric, score_qor
+
+
+def _metric(step, metric_id, value, dimension, direction="lower_is_better", **kwargs):
+ return QorScoringMetric(
+ step=step,
+ metric_id=metric_id,
+ value=value,
+ dimension=dimension,
+ direction=direction,
+ scope="workspace",
+ corner=None,
+ project_role=kwargs.get("project_role", "final"),
+ rating_score=kwargs.get("rating_score", True),
+ )
+
+
+def test_qor_scoring_selects_latest_area_and_combines_dimension_weights():
+ result = score_qor(
+ [
+ _metric("Floor", "die_area", 300, "area_cost"),
+ _metric("STA", "sta_setup_wns", -0.1, "timing", "higher_is_better"),
+ _metric("Harden", "die_area", 1500, "area_cost"),
+ _metric("DRC", "drc_count", 0, "routability_physical"),
+ ]
+ )
+
+ assert result.area_scoring_step == "Harden"
+ assert result.dimensions == {
+ "timing": (50.0, 1),
+ "routability_physical": (100.0, 1),
+ "area_cost": (50.0, 1),
+ }
+ assert result.overall_score == 42.5
From 150deaf3da6b3963bfe688bf500e6929d4771a66 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 01:05:56 +0800
Subject: [PATCH 07/25] refactor(cli): use headless signoff interface
---
chipcompiler/cli/command_handlers/signoff.py | 11 ++++++-----
test/cli/commands/test_signoff.py | 8 ++++----
2 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/chipcompiler/cli/command_handlers/signoff.py b/chipcompiler/cli/command_handlers/signoff.py
index aa55c376..80f53e73 100644
--- a/chipcompiler/cli/command_handlers/signoff.py
+++ b/chipcompiler/cli/command_handlers/signoff.py
@@ -9,7 +9,7 @@ def inspect(command_input, ctx: CommandContext) -> CommandResult:
if failure is not None:
return failure
- from chipcompiler.runtime.signoff_export import inspect_signoff_package
+ from chipcompiler.engine.signoff_export import inspect_signoff_package
review = inspect_signoff_package(workspace)
project = ctx.project
@@ -56,17 +56,18 @@ def export(command_input, ctx: CommandContext) -> CommandResult:
if failure is not None:
return failure
- from chipcompiler.runtime.workspace_api import RuntimeApiError
-
try:
- from chipcompiler.runtime.signoff_export import export_signoff_package_archive
+ from chipcompiler.engine.signoff_export import (
+ SignoffExportError,
+ export_signoff_package_archive,
+ )
output_path = export_signoff_package_archive(
workspace,
command_input.output_path,
include_debug=command_input.include_debug,
)
- except RuntimeApiError as exc:
+ except SignoffExportError as exc:
return CommandResult.err(
[
error_record(
diff --git a/test/cli/commands/test_signoff.py b/test/cli/commands/test_signoff.py
index 9df84510..07947cf9 100644
--- a/test/cli/commands/test_signoff.py
+++ b/test/cli/commands/test_signoff.py
@@ -63,7 +63,7 @@ def fake_load_workspace(path):
def _patch_inspect(monkeypatch, review=REVIEW):
monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.inspect_signoff_package",
+ "chipcompiler.engine.signoff_export.inspect_signoff_package",
lambda workspace: review,
)
@@ -85,7 +85,7 @@ def fake_export(workspace, output_path, additional_files=None, *, include_debug=
return destination
monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.export_signoff_package_archive", fake_export
+ "chipcompiler.engine.signoff_export.export_signoff_package_archive", fake_export
)
return calls
@@ -226,13 +226,13 @@ def test_export_forwards_include_debug(
def test_export_incomplete_maps_to_error(
self, tmp_path, capsys, monkeypatch, create_cli_project, workspace_stub, plain_records
):
- from chipcompiler.runtime.workspace_api import RuntimeApiError
+ from chipcompiler.engine.signoff_export import SignoffExportError
project_dir = create_cli_project()
os.makedirs(os.path.join(project_dir, "default"))
_patch_export(
monkeypatch,
- error=RuntimeApiError("command_failed", "signoff package is incomplete: x"),
+ error=SignoffExportError("signoff package is incomplete: x"),
)
rc = cli_main.run(
From 84eb3c7fcdd8a1a2a8d995228e94bb2ed4c8fedf Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 01:25:57 +0800
Subject: [PATCH 08/25] refactor(runtime): move transport ownership to studio
---
agent/methods.py | 43 -
agent/requests.py | 57 -
agent/server.py | 50 -
agent/test/test_requests.py | 61 -
agent/test/test_workspace_api.py | 151 --
agent/workspace_api.py | 271 --
chipcompiler/cli/app.py | 2 -
chipcompiler/cli/command_handlers/project.py | 1 -
chipcompiler/cli/commands/rpc.py | 30 -
chipcompiler/docs/ecc-user-guide.cn.md | 23 +-
chipcompiler/docs/ecc-user-guide.en.md | 23 +-
chipcompiler/engine/flow.py | 2 +-
.../{runtime => engine}/subflow_events.py | 2 -
chipcompiler/runtime/__init__.py | 1 -
chipcompiler/runtime/events.py | 68 -
chipcompiler/runtime/methods.py | 213 --
chipcompiler/runtime/operations.py | 763 ------
chipcompiler/runtime/recovery.py | 53 -
chipcompiler/runtime/requests.py | 259 --
chipcompiler/runtime/rpc_dispatch.py | 40 -
chipcompiler/runtime/server.py | 138 -
chipcompiler/runtime/sessions.py | 151 --
chipcompiler/runtime/signoff_export.py | 285 ---
chipcompiler/runtime/stdio_server.py | 120 -
chipcompiler/runtime/transport.py | 61 -
chipcompiler/runtime/workspace_api.py | 2256 -----------------
chipcompiler/runtime/workspace_config_io.py | 71 -
chipcompiler/tools/ecc/subflow.py | 2 +-
chipcompiler/tools/ecc_sizer/subflow.py | 2 +-
chipcompiler/tools/yosys/subflow.py | 2 +-
chipcompiler/tools/yosys_lec/subflow.py | 2 +-
docs/development.cn.md | 9 +-
docs/development.md | 23 +-
docs/index.md | 4 +-
docs/rpc-guide.md | 282 ---
docs/specification/cli-design.md | 60 +-
ecc.spec | 5 -
pyproject.toml | 1 -
test/cli/test_cli_module_layout.py | 2 +-
test/cli/test_rpc_cli.py | 32 -
test/cli/test_typer_cli.py | 23 +-
test/data/test_workspace.py | 2 +-
.../test_subflow_events.py | 2 +-
test/packaging/test_cli_entrypoint.py | 4 +-
test/runtime/test_events.py | 156 --
test/runtime/test_layout_edit.py | 608 -----
test/runtime/test_methods.py | 154 --
test/runtime/test_operations.py | 518 ----
test/runtime/test_requests.py | 352 ---
test/runtime/test_rpc_create_parameters.py | 136 -
test/runtime/test_rpc_dispatch.py | 71 -
test/runtime/test_server.py | 429 ----
test/runtime/test_sessions.py | 101 -
test/runtime/test_signoff_export.py | 448 ----
test/runtime/test_stdio_server.py | 241 --
test/runtime/test_transport.py | 80 -
test/runtime/test_workspace_api.py | 1616 ------------
uv.lock | 197 --
58 files changed, 35 insertions(+), 10724 deletions(-)
delete mode 100644 agent/methods.py
delete mode 100644 agent/requests.py
delete mode 100644 agent/server.py
delete mode 100644 agent/test/test_requests.py
delete mode 100644 agent/test/test_workspace_api.py
delete mode 100644 agent/workspace_api.py
delete mode 100644 chipcompiler/cli/commands/rpc.py
rename chipcompiler/{runtime => engine}/subflow_events.py (98%)
delete mode 100644 chipcompiler/runtime/__init__.py
delete mode 100644 chipcompiler/runtime/events.py
delete mode 100644 chipcompiler/runtime/methods.py
delete mode 100644 chipcompiler/runtime/operations.py
delete mode 100644 chipcompiler/runtime/recovery.py
delete mode 100644 chipcompiler/runtime/requests.py
delete mode 100644 chipcompiler/runtime/rpc_dispatch.py
delete mode 100644 chipcompiler/runtime/server.py
delete mode 100644 chipcompiler/runtime/sessions.py
delete mode 100644 chipcompiler/runtime/signoff_export.py
delete mode 100644 chipcompiler/runtime/stdio_server.py
delete mode 100644 chipcompiler/runtime/transport.py
delete mode 100644 chipcompiler/runtime/workspace_api.py
delete mode 100644 chipcompiler/runtime/workspace_config_io.py
delete mode 100644 docs/rpc-guide.md
delete mode 100644 test/cli/test_rpc_cli.py
rename test/{runtime => engine}/test_subflow_events.py (92%)
delete mode 100644 test/runtime/test_events.py
delete mode 100644 test/runtime/test_layout_edit.py
delete mode 100644 test/runtime/test_methods.py
delete mode 100644 test/runtime/test_operations.py
delete mode 100644 test/runtime/test_requests.py
delete mode 100644 test/runtime/test_rpc_create_parameters.py
delete mode 100644 test/runtime/test_rpc_dispatch.py
delete mode 100644 test/runtime/test_server.py
delete mode 100644 test/runtime/test_sessions.py
delete mode 100644 test/runtime/test_signoff_export.py
delete mode 100644 test/runtime/test_stdio_server.py
delete mode 100644 test/runtime/test_transport.py
delete mode 100644 test/runtime/test_workspace_api.py
diff --git a/agent/methods.py b/agent/methods.py
deleted file mode 100644
index 14419ea2..00000000
--- a/agent/methods.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from typing import Any, Final
-
-from chipcompiler.runtime.methods import RuntimeMethodSpec
-from chipcompiler.runtime.requests import WorkspaceIdRequest
-
-from .requests import (
- CandidateBindInputRequest,
- CandidateMaterializeRequest,
- CandidateRerunRequest,
- WorkspaceExtractFoundationRequest,
-)
-
-AGENT_RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = (
- RuntimeMethodSpec(
- method_name="workspace.extract_foundation",
- request_model=WorkspaceExtractFoundationRequest,
- handler_name="extract_foundation",
- ),
- RuntimeMethodSpec(
- method_name="candidate.export_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",
- ),
- RuntimeMethodSpec(
- method_name="candidate.rerun",
- request_model=CandidateRerunRequest,
- handler_name="candidate_rerun",
- ),
-)
-
-
-def agent_method_names() -> tuple[str, ...]:
- return tuple(spec.method_name for spec in AGENT_RUNTIME_METHODS)
diff --git a/agent/requests.py b/agent/requests.py
deleted file mode 100644
index 30be9d4a..00000000
--- a/agent/requests.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from dataclasses import dataclass
-from typing import Any
-
-from chipcompiler.runtime.requests import RequestValidationError, parse_request_model
-
-
-@dataclass(frozen=True)
-class WorkspaceExtractFoundationRequest:
- workspace_id: str
-
-
-@dataclass(frozen=True)
-class CandidateBindInputRequest:
- workspace_id: str
- target_step: str
- source_step: str
- candidate_id: str
-
-
-@dataclass(frozen=True)
-class CandidateMaterializeRequest:
- workspace_id: str
- target_step: str
- candidate_id: str
- patch: list[dict[str, Any]]
-
-
-@dataclass(frozen=True)
-class CandidateRerunRequest:
- workspace_id: str
- target_step: str
- end_step: str
- candidate_id: str
- patch: list[dict[str, Any]]
- execution_scope: str
-
-
-_FIELD_ALIASES = {
- "workspaceId": "workspace_id",
- "targetStep": "target_step",
- "endStep": "end_step",
- "sourceStep": "source_step",
- "candidateId": "candidate_id",
- "executionScope": "execution_scope",
-}
-
-
-def parse_agent_request_model(model: type, params: object):
- if not isinstance(params, dict):
- raise RequestValidationError("params must be an object")
- normalized = {}
- for key, value in params.items():
- name = _FIELD_ALIASES.get(str(key), str(key))
- if name in normalized:
- raise RequestValidationError(f"duplicate field: {name}")
- normalized[name] = value
- return parse_request_model(model, normalized)
diff --git a/agent/server.py b/agent/server.py
deleted file mode 100644
index d5a1b0ef..00000000
--- a/agent/server.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from jsonrpcserver import Error
-
-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 .workspace_api import FlowAgentRuntimeApi
-
-
-class AgentRuntimeServer(RuntimeServer):
- def __init__(
- self,
- api: WorkspaceRuntimeApi | None = None,
- *,
- persistent_db_enabled: bool = False,
- ):
- super().__init__(api=api, persistent_db_enabled=persistent_db_enabled)
- self.agent_api = FlowAgentRuntimeApi(self.api)
- self._register_agent_methods()
-
- @property
- def capabilities(self) -> tuple[str, ...]:
- return (*super().capabilities, *agent_method_names())
-
- def _register_agent_methods(self) -> None:
- for spec in AGENT_RUNTIME_METHODS:
- handler = getattr(self.agent_api, spec.handler_name)
- self.dispatcher.add_method(spec.method_name, self._agent_method_handler(spec, handler))
-
- @staticmethod
- def _agent_method_handler(spec, handler):
- def dispatch(**params):
- try:
- request = parse_agent_request_model(spec.request_model, params)
- return handler(request)
- except RuntimeApiError as exc:
- return Error(
- ERROR_CODES.get(exc.code, -32000),
- exc.code,
- {"message": exc.message, **exc.data},
- )
- except Exception as exc:
- return Error(
- ERROR_CODES["command_failed"],
- "command_failed",
- {"message": str(exc)},
- )
-
- return dispatch
diff --git a/agent/test/test_requests.py b/agent/test/test_requests.py
deleted file mode 100644
index a27f6ff5..00000000
--- a/agent/test/test_requests.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import pytest
-
-from agent.methods import agent_method_names
-from agent.requests import CandidateRerunRequest, parse_agent_request_model
-from agent.server import AgentRuntimeServer
-from chipcompiler.runtime.requests import RequestValidationError
-
-
-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",
- )
-
-
-def test_agent_runtime_server_registers_isolated_methods():
- server = AgentRuntimeServer()
-
- assert set(agent_method_names()).issubset(server.capabilities)
-
-
-def test_agent_request_normalizes_camel_case_fields():
- request = parse_agent_request_model(
- CandidateRerunRequest,
- {
- "workspaceId": "workspace-1",
- "targetStep": "place",
- "endStep": "CTS",
- "candidateId": "candidate-1",
- "patch": [],
- "executionScope": "full_flow",
- },
- )
-
- assert request == CandidateRerunRequest(
- workspace_id="workspace-1",
- target_step="place",
- end_step="CTS",
- candidate_id="candidate-1",
- patch=[],
- execution_scope="full_flow",
- )
-
-
-def test_agent_request_rejects_duplicate_aliases():
- with pytest.raises(RequestValidationError, match="duplicate field: workspace_id"):
- parse_agent_request_model(
- CandidateRerunRequest,
- {
- "workspaceId": "workspace-1",
- "workspace_id": "workspace-1",
- "targetStep": "place",
- "endStep": "place",
- "candidateId": "candidate-1",
- "patch": [],
- "executionScope": "single_step",
- },
- )
diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py
deleted file mode 100644
index f1106b93..00000000
--- a/agent/test/test_workspace_api.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from pathlib import Path
-from types import SimpleNamespace
-
-from agent.requests import CandidateRerunRequest
-from agent.workspace_api import FlowAgentRuntimeApi, _candidate_step_artifact_dirs
-from chipcompiler.data import StateEnum
-from chipcompiler.data.workspace.layout import EccOutput
-
-
-def test_candidate_artifact_dirs_support_typed_step_outputs(tmp_path):
- output_dir = tmp_path / "place_dreamplace" / "output"
- analysis_dir = tmp_path / "place_dreamplace" / "analysis"
- step = SimpleNamespace(
- output=EccOutput(dir=output_dir),
- analysis={"dir": analysis_dir},
- )
-
- 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):
- 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"},
- ]
- }
- ),
- )
- 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):
- 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)
- 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)
- ),
- )
- monkeypatch.setattr(
- "agent.workspace_api.validate_candidate_step_contract",
- lambda _ws, _target: "candidate-1",
- )
- monkeypatch.setattr(
- "agent.workspace_api.reapply_candidate_input_binding",
- lambda _ws, _flow, target: calls.append(("reapply", target)),
- )
- monkeypatch.setattr(
- "agent.workspace_api._init_db_engine_for_workspace_step",
- lambda _flow, step: calls.append(("init", step.name)),
- )
-
- result = 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",
- )
- )
-
- assert result == {
- "target_step": "place",
- "end_step": "CTS",
- "execution_scope": "full_flow",
- }
- assert calls == [
- ("bind", "place", "Floorplan", "candidate-1"),
- (
- "materialize",
- "place",
- [{"knob_id": "place.target_density", "value": 0.6}],
- "candidate-1",
- ),
- ("reapply", "place"),
- ("init", "place"),
- ("init", "CTS"),
- ]
- assert flow.run_calls == [("place", True), ("CTS", True)]
- assert not list(place_output.iterdir())
- assert not list(place_analysis.iterdir())
- assert not list(cts_output.iterdir())
-
-
-class _EccApi:
- def __init__(self, workspace):
- self.session = SimpleNamespace(workspace=workspace, db_handle=None)
-
- def _with_session_mutation_lock(self, workspace_id, operation):
- assert workspace_id == "workspace-1"
- return operation(self.session)
-
- def _should_capture_session_db(self, _session):
- return False
-
- 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.run_calls = []
-
- 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):
- return True
-
- def run_step(self, step, *, rerun):
- self.run_calls.append((step.name, rerun))
- return StateEnum.Success
diff --git a/agent/workspace_api.py b/agent/workspace_api.py
deleted file mode 100644
index 9c310a17..00000000
--- a/agent/workspace_api.py
+++ /dev/null
@@ -1,271 +0,0 @@
-import json
-import shutil
-from hashlib import sha256
-from pathlib import Path
-
-from chipcompiler.runtime.requests import WorkspaceIdRequest
-from chipcompiler.runtime.workspace_api import (
- RuntimeApiError,
- WorkspaceRuntimeApi,
- _init_db_engine_for_workspace_step,
- _state_value,
-)
-from chipcompiler.utility.path import path_is_within
-
-from .data import (
- FoundationExtractor,
- bind_candidate_input,
- export_candidate_capabilities,
- materialize_candidate_config,
- reapply_candidate_input_binding,
- validate_candidate_step_contract,
-)
-from .engine import AgentEngineFlow
-from .requests import (
- CandidateBindInputRequest,
- CandidateMaterializeRequest,
- CandidateRerunRequest,
- WorkspaceExtractFoundationRequest,
-)
-
-
-def build_agent_flow_for_workspace(workspace, *, create_step_workspaces: bool = True):
- import chipcompiler.rtl2gds as rtl2gds_api
-
- flow = AgentEngineFlow(workspace=workspace)
- if not flow.has_init():
- for step, tool, state in rtl2gds_api.build_rtl2gds_flow():
- flow.add_step(step=step, tool=tool, state=state)
- if create_step_workspaces:
- flow.create_step_workspaces()
- return flow
-
-
-class FlowAgentRuntimeApi:
- """Optional Flow Agent RPC handlers over one ECC workspace runtime."""
-
- def __init__(self, ecc_api: WorkspaceRuntimeApi):
- self.ecc_api = ecc_api
-
- def extract_foundation(self, request: WorkspaceExtractFoundationRequest) -> dict:
- def extract(session):
- workspace_dir = Path(session.workspace.directory).resolve()
- FoundationExtractor(str(workspace_dir), profile="iccd_full_v1").extract(
- include_raw_refs=False,
- materialize_audit_tables=True,
- route_detail_level="full",
- )
- return _foundation_receipt(workspace_dir)
-
- return self._with_workspace_lock(request.workspace_id, extract)
-
- def export_candidate_capabilities(self, request: WorkspaceIdRequest) -> dict:
- return self._with_workspace_lock(
- request.workspace_id,
- lambda session: export_candidate_capabilities(session.workspace),
- )
-
- def bind_candidate_input(self, request: CandidateBindInputRequest) -> dict:
- def bind(session):
- flow = build_agent_flow_for_workspace(session.workspace)
- return bind_candidate_input(
- session.workspace,
- flow,
- request.target_step,
- request.source_step,
- request.candidate_id,
- )
-
- return self._with_workspace_lock(request.workspace_id, bind)
-
- def materialize_candidate(self, request: CandidateMaterializeRequest) -> dict:
- return self._with_workspace_lock(
- request.workspace_id,
- lambda session: materialize_candidate_config(
- session.workspace,
- request.target_step,
- request.patch,
- request.candidate_id,
- ),
- )
-
- def candidate_rerun(self, request: CandidateRerunRequest) -> dict:
- 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)
- try:
- steps = _candidate_rerun_steps(
- flow,
- request.target_step,
- request.end_step,
- request.execution_scope,
- )
- 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,
- }
- finally:
- self._finish_flow(
- session,
- flow,
- should_capture=should_capture,
- previous_db=previous_db,
- )
-
- def _build_flow(self, session):
- flow = build_agent_flow_for_workspace(session.workspace)
- return flow
-
- def _finish_flow(self, session, flow, *, should_capture: bool, previous_db) -> None:
- if should_capture:
- self.ecc_api._capture_flow_db(session, flow, previous_handle=previous_db)
- else:
- self.ecc_api._close_transient_flow_db(flow)
-
- def _with_workspace_lock(self, workspace_id: str, operation):
- return self.ecc_api._with_session_mutation_lock(workspace_id, operation)
-
-
-def _foundation_receipt(workspace_dir: Path) -> dict:
- manifest = workspace_dir / "foundation_data/ecc/manifest.json"
- try:
- payload = json.loads(manifest.read_text(encoding="utf-8"))
- except (OSError, ValueError) as exc:
- raise RuntimeApiError("command_failed", f"foundation extraction failed: {exc}") from exc
- if payload.get("contract_name") != "foundation_data/ecc":
- raise RuntimeApiError(
- "command_failed", "foundation extractor produced an unsupported contract"
- )
- return {
- "manifestRef": "foundation_data/ecc/manifest.json",
- "manifestSha256": sha256(manifest.read_bytes()).hexdigest(),
- "contractName": payload["contract_name"],
- "schemaVersion": payload.get("schema_version"),
- }
-
-
-def _candidate_rerun_steps(flow, target_step: str, end_step: str, execution_scope: str) -> list:
- if execution_scope not in {"single_step", "full_flow"}:
- raise RuntimeApiError("invalid_request", "candidate rerun execution scope is invalid")
- steps = list(getattr(flow, "workspace_steps", ()))
- target_index = next(
- (index for index, step in enumerate(steps) if step.name == target_step), None
- )
- 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}"
- )
- if execution_scope == "single_step":
- if target_step != end_step:
- raise RuntimeApiError(
- "invalid_request", "single-step rerun end step must match the target step"
- )
- return [steps[target_index]]
- if end_index < target_index:
- raise RuntimeApiError("invalid_request", "rerun end step precedes the target step")
- return steps[target_index : end_index + 1]
-
-
-def _materialize_candidate_rerun(workspace, flow, request: CandidateRerunRequest) -> None:
- source_step = _candidate_source_step(flow, request.target_step)
- bind_candidate_input(
- workspace,
- flow,
- request.target_step,
- source_step,
- request.candidate_id,
- )
- materialize_candidate_config(
- workspace,
- request.target_step,
- request.patch,
- request.candidate_id,
- )
-
-
-def _candidate_source_step(flow, target_step: str) -> str:
- steps = list(getattr(flow, "workspace_steps", ()))
- for index, step in enumerate(steps):
- if step.name == target_step and index:
- return steps[index - 1].name
- raise RuntimeApiError("invalid_request", f"candidate target has no predecessor: {target_step}")
-
-
-def _reapply_candidate_input(workspace, flow, target_step: str) -> None:
- try:
- candidate_id = validate_candidate_step_contract(workspace, target_step)
- if candidate_id is not None:
- reapply_candidate_input_binding(workspace, flow, target_step)
- except ValueError as error:
- raise RuntimeApiError(
- "command_failed", f"candidate contract is invalid for {target_step}: {error}"
- ) from error
-
-
-def _prepare_candidate_rerun(workspace, flow, steps: list) -> None:
- workspace_root = Path(workspace.directory).resolve()
- for step in steps:
- for directory in _candidate_step_artifact_dirs(step):
- _clear_candidate_artifact_dir(workspace_root, directory, step.name)
- record = flow.get_step(step.name, step.tool)
- if record is None:
- raise RuntimeApiError("command_failed", f"candidate flow state is missing: {step.name}")
- record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0})
- flow.save()
-
-
-def _candidate_step_artifact_dirs(step) -> tuple[Path, ...]:
- directories = []
- for field in ("output", "data", "feature", "analysis", "report", "log"):
- value = getattr(step, field, {})
- directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None)
- if directory:
- directories.append(Path(directory))
- return tuple(dict.fromkeys(directories))
-
-
-def _clear_candidate_artifact_dir(workspace_root: Path, directory: Path, step_name: str) -> None:
- resolved = directory.resolve()
- if (
- resolved == workspace_root
- or not path_is_within(resolved, workspace_root)
- or directory.is_symlink()
- ):
- raise RuntimeApiError(
- "command_failed", f"candidate artifact escapes workspace: {step_name}"
- )
- if directory.exists():
- if not directory.is_dir():
- raise RuntimeApiError(
- "command_failed", f"candidate artifact is not a directory: {step_name}"
- )
- shutil.rmtree(directory)
- directory.mkdir(parents=True, exist_ok=True)
-
-
-def _run_candidate_step(flow, step) -> None:
- _init_db_engine_for_workspace_step(flow, step)
- state = flow.run_step(step, rerun=True)
- if _state_value(state) != "Success":
- raise RuntimeApiError(
- "command_failed",
- f"candidate rerun step {step.name} failed with state {_state_value(state)}",
- )
diff --git a/chipcompiler/cli/app.py b/chipcompiler/cli/app.py
index 57b60656..52dda2d6 100644
--- a/chipcompiler/cli/app.py
+++ b/chipcompiler/cli/app.py
@@ -12,7 +12,6 @@
from chipcompiler.cli.commands.project import register_project_commands
from chipcompiler.cli.commands.project_config import project_app
from chipcompiler.cli.commands.report import report_app
-from chipcompiler.cli.commands.rpc import rpc_app
from chipcompiler.cli.commands.signoff import signoff_app
from chipcompiler.cli.commands.workspace import workspace_app
from chipcompiler.cli.core.apps import create_app
@@ -82,7 +81,6 @@ def layout_image_cmd(
app.add_typer(workspace_app, name="workspace")
app.add_typer(signoff_app, name="signoff")
app.add_typer(report_app, name="report")
-app.add_typer(rpc_app, name="rpc")
def invoke_typer_app(argv: Sequence[str]) -> int:
diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py
index 6ea8a597..692ca15b 100644
--- a/chipcompiler/cli/command_handlers/project.py
+++ b/chipcompiler/cli/command_handlers/project.py
@@ -422,7 +422,6 @@ def error(kind: str, **fields) -> CommandResult:
layer_warnings.append(set_warning)
# TODO: Move non-interactive project run preparation/execution into
- # chipcompiler.runtime.project_runner.run_project or
# chipcompiler.engine.project_run.prepare_and_run. Keep CLI ownership limited
# to input parsing, progress renderer selection, and CommandResult mapping.
project_state = ctx.project_state
diff --git a/chipcompiler/cli/commands/rpc.py b/chipcompiler/cli/commands/rpc.py
deleted file mode 100644
index 184b9629..00000000
--- a/chipcompiler/cli/commands/rpc.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from typing import Annotated
-
-import typer
-
-from chipcompiler.cli.core.apps import create_app
-
-rpc_app = create_app(help="Run the private ECC JSON-RPC runtime")
-
-
-@rpc_app.command("serve", help="Serve the private ECC JSON-RPC runtime")
-def serve_cmd(
- *,
- stdio: Annotated[
- bool,
- typer.Option("--stdio", help="Use Content-Length framed stdio transport."),
- ] = False,
- persistent_db: Annotated[
- bool,
- typer.Option(
- "--persistent-db",
- help="Enable explicit persistent DB lifecycle RPC methods.",
- ),
- ] = False,
-) -> None:
- if not stdio:
- raise typer.BadParameter("--stdio is required", param_hint="--stdio")
-
- from chipcompiler.runtime.stdio_server import main
-
- raise typer.Exit(code=main(persistent_db_enabled=persistent_db))
diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md
index 4cc5c1a1..d1f181b6 100644
--- a/chipcompiler/docs/ecc-user-guide.cn.md
+++ b/chipcompiler/docs/ecc-user-guide.cn.md
@@ -4,7 +4,6 @@
- 源码位置:[chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/)
- 命令扩展开发方式见 [development.cn.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.cn.md#扩展-cli)
-- RPC sidecar 协议详见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)
## 0. 调用方式
@@ -81,7 +80,7 @@ uv run ecc --help
- 全局:`ecc --version`(单行版本号)、`ecc --help`。
- 项目定位:项目级命令接受 `--project
`(缺省为当前目录)。`--workspace <名称>` 是项目内受管的、非空单路径段名称,不能传文件系统路径。新项目裸执行 `ecc run` 创建 `default`;只有一个活跃 workspace 时自动选择,多个活跃 workspace 时必须指定 `--workspace`。命名 workspace 会在创建文件前登记到 `project.json`。遗留的 `runs/` 项目必须先执行 `ecc migrate`。每个项目只有一个 `ecc.toml`;创建时会把声明的输入复制到各 workspace 的 `origin/`。
-- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`rpc serve` 和 `layout-image` 使用各自的协议。
+- 结构化输出:`init`、`check`、`run`、`status`、`log`、`config`、`migrate`、`doctor`、`param`、`pdk`、`project`、`workspace`、`signoff`、`report` 都支持 `--plain`(`key=value`,便于脚本解析),缺省为人类可读 TEXT。`layout-image` 使用自己的协议。
- 退出码:成功 0;业务失败 1(错误记录形如 `[error] error=<机器可读错误码>`)。
- 步骤名(step token)有三套写法,按场景区分:
- **展示名**(`ecc status` / `ecc log` / `ecc report step` 的输出与入参,统一小写/下划线):`synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`;
@@ -958,23 +957,7 @@ $ ecc report step drc --section analysis
[BLOCK] qor.drc.clean — drc_count=336 == 0
```
-## 13. rpc — JSON-RPC runtime sidecar(私有)
-
-```bash
-ecc rpc serve --stdio [--persistent-db]
-```
-
-供 GUI 等前端使用的 JSON-RPC 2.0 服务,`Content-Length` 帧封装于 stdio。`--persistent-db` 额外开放 `db.ensure` / `db.release` 与 `layout.edit.*` / `floorplan.edit.*` 系列方法。握手与调用示例(完整方法列表和参数见 [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)):
-
-```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","method":"rpc.ping","params":{},"id":"ping-1"}
-← {"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"}
-```
-
-## 14. layout-image — GDS 渲染为图片
+## 13. layout-image — GDS 渲染为图片
```bash
ecc layout-image --gds --image [--width N] [--height N]
@@ -986,7 +969,7 @@ ecc layout-image --gds --image [--width N] [--height N]
ecc layout-image --gds default/Harden_ecc/output/gcd_Harden.gds --image layout.png --width 2560 --height 1600
```
-## 15. 端到端典型工作流
+## 14. 端到端典型工作流
```bash
ecc init gcd && cd gcd
diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md
index 16abb4c3..766c4614 100644
--- a/chipcompiler/docs/ecc-user-guide.en.md
+++ b/chipcompiler/docs/ecc-user-guide.en.md
@@ -4,7 +4,6 @@
- Source code: [chipcompiler/cli/](https://github.com/openecos-projects/ecc/tree/main/chipcompiler/cli/)
- For how to extend the CLI with new commands, see [development.md](https://github.com/openecos-projects/ecc/blob/main/docs/development.md#extending-the-cli)
-- RPC sidecar protocol: [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)
## 0. Invocation
@@ -81,7 +80,7 @@ uv run ecc --help
- Global: `ecc --version` (single version line), `ecc --help`.
- Project location: project-scoped commands accept `--project ` (defaults to the current directory). `--workspace ` is a managed, non-empty single path segment in that project, never a filesystem path. A fresh project creates `default` on bare `ecc run`; a project with one active workspace auto-selects it, while one with multiple active workspaces requires `--workspace`. A named workspace is created and registered in `project.json` before its files are created. Legacy `runs/` projects must be upgraded with `ecc migrate` before running a flow. Each project has one `ecc.toml`; workspace inputs are copied to its own `origin/` directory at creation time.
-- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `rpc serve` and `layout-image` use their own protocols instead.
+- Structured output: `init`, `check`, `run`, `status`, `log`, `config`, `migrate`, `doctor`, `param`, `pdk`, `project`, `workspace`, `signoff`, and `report` accept `--plain` (`key=value`, for scripting), with human-readable TEXT by default. `layout-image` uses its own protocol instead.
- Exit codes: 0 on success; 1 on business failure (error records look like `[error] error=`).
- Step tokens come in three vocabularies, distinguished by context:
- **display names** (output and input of `ecc status` / `ecc log` / `ecc report step`, uniformly lowercase/underscore): `synthesis / lec / floorplan / placement / cts / legalization / timing_optimization / routing / filler / rcx / sta / lvs / postroutelec / drc / harden`;
@@ -1006,23 +1005,7 @@ $ ecc report step drc --section analysis
[BLOCK] qor.drc.clean — drc_count=336 == 0
```
-## 13. rpc — JSON-RPC runtime sidecar (private)
-
-```bash
-ecc rpc serve --stdio [--persistent-db]
-```
-
-A JSON-RPC 2.0 service for front ends such as the GUI, framed with `Content-Length` over stdio. `--persistent-db` additionally exposes `db.ensure` / `db.release` plus the `layout.edit.*` / `floorplan.edit.*` method families. Handshake and call examples (full method list and parameters in [rpc-guide.md](https://github.com/openecos-projects/ecc/blob/main/docs/rpc-guide.md)):
-
-```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","method":"rpc.ping","params":{},"id":"ping-1"}
-← {"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"}
-```
-
-## 14. layout-image — render a GDS to an image
+## 13. layout-image — render a GDS to an image
```bash
ecc layout-image --gds --image [--width N] [--height N]
@@ -1034,7 +1017,7 @@ Renders a GDS layout snapshot via KLayout (default 1920×1920; KLayout must be a
ecc layout-image --gds default/Harden_ecc/output/gcd_Harden.gds --image layout.png --width 2560 --height 1600
```
-## 15. Typical end-to-end workflow
+## 14. Typical end-to-end workflow
```bash
ecc init gcd && cd gcd
diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py
index 63ed7991..af8981c0 100644
--- a/chipcompiler/engine/flow.py
+++ b/chipcompiler/engine/flow.py
@@ -805,7 +805,7 @@ def _finalize_interrupted_subflow(
peak_memory_mb: float,
) -> None:
try:
- from chipcompiler.runtime.subflow_events import finalize_interrupted_subflow
+ from chipcompiler.engine.subflow_events import finalize_interrupted_subflow
for subflow_step in finalize_interrupted_subflow(
workspace_step,
diff --git a/chipcompiler/runtime/subflow_events.py b/chipcompiler/engine/subflow_events.py
similarity index 98%
rename from chipcompiler/runtime/subflow_events.py
rename to chipcompiler/engine/subflow_events.py
index d72adb74..c47917f7 100644
--- a/chipcompiler/runtime/subflow_events.py
+++ b/chipcompiler/engine/subflow_events.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
from copy import deepcopy
from typing import Any
diff --git a/chipcompiler/runtime/__init__.py b/chipcompiler/runtime/__init__.py
deleted file mode 100644
index 6777b640..00000000
--- a/chipcompiler/runtime/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Private ECC runtime support for long-lived sidecar sessions."""
diff --git a/chipcompiler/runtime/events.py b/chipcompiler/runtime/events.py
deleted file mode 100644
index aa59bba2..00000000
--- a/chipcompiler/runtime/events.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import os
-import sys
-from contextlib import contextmanager, suppress
-
-from chipcompiler.utility.log import stdio_redirect_lock
-
-
-@contextmanager
-def redirect_stdout_to_stderr():
- acquired = stdio_redirect_lock.acquire(blocking=False)
- if not acquired:
- # A tool already owns fd 1/2. Its protocol writer uses a duplicated fd,
- # so the RPC can proceed without installing a competing redirect.
- yield
- return
- try:
- with _redirect_stdout_to_stderr():
- yield
- finally:
- stdio_redirect_lock.release()
-
-
-@contextmanager
-def _redirect_stdout_to_stderr():
- saved_stdout = sys.stdout
- saved_stderr = sys.stderr
- saved_stdout_fd = None
- saved_stderr_fd = None
-
- with suppress(Exception):
- sys.stdout.flush()
- with suppress(Exception):
- sys.stderr.flush()
-
- try:
- saved_stdout_fd = os.dup(1)
- saved_stderr_fd = os.dup(2)
- os.dup2(2, 1)
- sys.stdout = sys.stderr
- except OSError:
- with suppress(Exception):
- if saved_stdout_fd is not None:
- os.close(saved_stdout_fd)
- if saved_stderr_fd is not None:
- os.close(saved_stderr_fd)
- sys.stdout = sys.stderr
- try:
- yield
- finally:
- sys.stdout = saved_stdout
- sys.stderr = saved_stderr
- return
-
- try:
- yield
- finally:
- with suppress(Exception):
- sys.stdout.flush()
- with suppress(Exception):
- sys.stderr.flush()
- try:
- os.dup2(saved_stdout_fd, 1)
- os.dup2(saved_stderr_fd, 2)
- finally:
- os.close(saved_stdout_fd)
- os.close(saved_stderr_fd)
- sys.stdout = saved_stdout
- sys.stderr = saved_stderr
diff --git a/chipcompiler/runtime/methods.py b/chipcompiler/runtime/methods.py
deleted file mode 100644
index f17f4e50..00000000
--- a/chipcompiler/runtime/methods.py
+++ /dev/null
@@ -1,213 +0,0 @@
-from dataclasses import dataclass
-from typing import Any, Final, Generic, TypeVar
-
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- FloorplanEditInspectRequest,
- FloorplanEditRunAutoRequest,
- FloorplanEditValidateRequest,
- FlowRunRequest,
- FlowRunStepRequest,
- LayoutEditApplyRequest,
- LayoutEditBeginRequest,
- LayoutEditDiscardRequest,
- LayoutEditSaveRequest,
- OperationAckStepRenderedRequest,
- OperationIdRequest,
- OperationStartFlowRequest,
- OperationStartStepRequest,
- WorkspaceCloseRequest,
- WorkspaceCreateRequest,
- WorkspaceExportSignoffRequest,
- WorkspaceIdRequest,
- WorkspaceInfoRequest,
- WorkspaceInspectSignoffRequest,
- WorkspaceOpenRequest,
- WorkspaceRecoverInterruptedRequest,
- WorkspaceSyncConfigRequest,
-)
-
-RequestT = TypeVar("RequestT")
-
-
-@dataclass(frozen=True)
-class RuntimeMethodSpec(Generic[RequestT]):
- method_name: str
- request_model: type[RequestT]
- handler_name: str
-
-
-RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = (
- RuntimeMethodSpec(
- method_name="workspace.create",
- request_model=WorkspaceCreateRequest,
- handler_name="create_workspace",
- ),
- RuntimeMethodSpec(
- method_name="workspace.open",
- request_model=WorkspaceOpenRequest,
- handler_name="open_workspace",
- ),
- RuntimeMethodSpec(
- method_name="workspace.close",
- request_model=WorkspaceCloseRequest,
- handler_name="close_workspace",
- ),
- RuntimeMethodSpec(
- method_name="workspace.home",
- request_model=WorkspaceIdRequest,
- handler_name="workspace_home",
- ),
- RuntimeMethodSpec(
- method_name="workspace.info",
- request_model=WorkspaceInfoRequest,
- handler_name="workspace_info",
- ),
- RuntimeMethodSpec(
- method_name="workspace.refresh_config",
- request_model=WorkspaceIdRequest,
- handler_name="refresh_config",
- ),
- RuntimeMethodSpec(
- method_name="workspace.sync_config",
- request_model=WorkspaceSyncConfigRequest,
- handler_name="sync_config",
- ),
- RuntimeMethodSpec(
- method_name="workspace.reset_flow",
- request_model=WorkspaceIdRequest,
- handler_name="reset_flow",
- ),
- RuntimeMethodSpec(
- method_name="workspace.export_signoff",
- request_model=WorkspaceExportSignoffRequest,
- handler_name="export_signoff",
- ),
- RuntimeMethodSpec(
- method_name="workspace.inspect_signoff",
- request_model=WorkspaceInspectSignoffRequest,
- handler_name="inspect_signoff",
- ),
- RuntimeMethodSpec(
- method_name="flow.run",
- request_model=FlowRunRequest,
- handler_name="flow_run",
- ),
- RuntimeMethodSpec(
- method_name="flow.run_step",
- request_model=FlowRunStepRequest,
- handler_name="flow_run_step",
- ),
- RuntimeMethodSpec(
- method_name="operation.start_flow",
- request_model=OperationStartFlowRequest,
- handler_name="start_flow_operation",
- ),
- RuntimeMethodSpec(
- method_name="operation.start_step",
- request_model=OperationStartStepRequest,
- handler_name="start_step_operation",
- ),
- RuntimeMethodSpec(
- method_name="operation.status",
- request_model=OperationIdRequest,
- handler_name="operation_status",
- ),
- RuntimeMethodSpec(
- method_name="operation.cancel",
- request_model=OperationIdRequest,
- handler_name="cancel_operation",
- ),
- RuntimeMethodSpec(
- method_name="operation.ack_step_rendered",
- request_model=OperationAckStepRenderedRequest,
- handler_name="acknowledge_step_rendered",
- ),
- RuntimeMethodSpec(
- method_name="workspace.snapshot",
- request_model=WorkspaceIdRequest,
- handler_name="workspace_snapshot",
- ),
- RuntimeMethodSpec(
- method_name="workspace.recover_interrupted",
- request_model=WorkspaceRecoverInterruptedRequest,
- handler_name="recover_interrupted",
- ),
-)
-
-
-PERSISTENT_DB_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = (
- RuntimeMethodSpec(
- method_name="db.ensure",
- request_model=DbEnsureRequest,
- handler_name="db_ensure",
- ),
- RuntimeMethodSpec(
- method_name="db.release",
- request_model=DbReleaseRequest,
- handler_name="db_release",
- ),
- RuntimeMethodSpec(
- method_name="layout.edit.begin",
- request_model=LayoutEditBeginRequest,
- handler_name="layout_edit_begin",
- ),
- RuntimeMethodSpec(
- method_name="layout.edit.apply",
- request_model=LayoutEditApplyRequest,
- handler_name="layout_edit_apply",
- ),
- RuntimeMethodSpec(
- method_name="layout.edit.save",
- request_model=LayoutEditSaveRequest,
- handler_name="layout_edit_save",
- ),
- RuntimeMethodSpec(
- method_name="layout.edit.discard",
- request_model=LayoutEditDiscardRequest,
- handler_name="layout_edit_discard",
- ),
- RuntimeMethodSpec(
- method_name="floorplan.edit.inspect",
- request_model=FloorplanEditInspectRequest,
- handler_name="floorplan_edit_inspect",
- ),
- RuntimeMethodSpec(
- method_name="floorplan.edit.run_auto",
- request_model=FloorplanEditRunAutoRequest,
- handler_name="floorplan_edit_run_auto",
- ),
- RuntimeMethodSpec(
- method_name="floorplan.edit.validate",
- request_model=FloorplanEditValidateRequest,
- handler_name="floorplan_edit_validate",
- ),
-)
-
-
-def runtime_methods(*, persistent_db_enabled: bool = False) -> tuple[RuntimeMethodSpec[Any], ...]:
- if persistent_db_enabled:
- return (*RUNTIME_METHODS, *PERSISTENT_DB_METHODS)
- return RUNTIME_METHODS
-
-
-def runtime_method_names(*, persistent_db_enabled: bool = False) -> tuple[str, ...]:
- return tuple(
- spec.method_name for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled)
- )
-
-
-def persistent_db_method_names() -> tuple[str, ...]:
- return tuple(spec.method_name for spec in PERSISTENT_DB_METHODS)
-
-
-def runtime_method_by_name(
- method_name: str,
- *,
- persistent_db_enabled: bool = False,
-) -> RuntimeMethodSpec[Any] | None:
- for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled):
- if spec.method_name == method_name:
- return spec
- return None
diff --git a/chipcompiler/runtime/operations.py b/chipcompiler/runtime/operations.py
deleted file mode 100644
index eed9292a..00000000
--- a/chipcompiler/runtime/operations.py
+++ /dev/null
@@ -1,763 +0,0 @@
-from __future__ import annotations
-
-import threading
-import time
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-from uuid import uuid4
-
-_LOG_POLL_INTERVAL_SECONDS = 0.25
-_MAX_LOG_CHUNK_BYTES = 16 * 1024
-_MAX_FINAL_LOG_BYTES = 64 * 1024
-_RENDER_ACK_RETRY_SECONDS = 5.0
-_RENDER_ACK_PAUSE_SECONDS = 30.0
-_RENDER_ACK_ABORT_SECONDS = 300.0
-_TERMINAL_OPERATION_STATES = frozenset({"succeeded", "failed", "cancelled"})
-
-
-@dataclass
-class _StepLogTail:
- """A bounded worker-side reader for one active step log."""
-
- operation_id: str
- path: Path
- step: str
- tool: str
- cursor: int
- stopped: threading.Event = field(default_factory=threading.Event)
- thread: threading.Thread | None = None
-
-
-class RuntimeOperationConflict(RuntimeError):
- """A workspace already owns a non-terminal runtime operation."""
-
-
-class RuntimeOperationCancelled(RuntimeError):
- """Cancellation was accepted at a safe step boundary."""
-
-
-@dataclass
-class RuntimeOperation:
- operation_id: str
- run_session_id: str
- runtime_instance_id: str
- workspace_id: str
- kind: str
- origin: str
- rerun: bool
- step: str = ""
- idempotency_key: str = ""
- state: str = "queued"
- current_step: str = ""
- current_tool: str = ""
- error: dict[str, Any] | None = None
- result: dict[str, Any] | None = None
- created_at: float = field(default_factory=time.time)
- updated_at: float = field(default_factory=time.time)
- sequence: int = 0
- awaiting_event_id: str | None = None
- awaiting_event: dict[str, Any] | None = None
- awaiting_step_commit_id: str | None = None
- workspace_revision: int = 0
- render_sync_state: str = "idle"
- render_retry_count: int = 0
- render_wait_started_at: float | None = None
- last_render_ack_at: float | None = None
- render_sync_degraded: bool = False
- acked_event_ids: set[str] = field(default_factory=set)
- cancel_requested: bool = False
- interruptibility: str = "deferred"
-
-
-class RuntimeOperationManager:
- """Owns asynchronous GUI operations and their exactly-once event stream."""
-
- def __init__(self, publisher: Callable[[dict[str, Any]], None] | None = None):
- self._publisher = publisher
- self._lock = threading.RLock()
- self._render_gate = threading.Condition(self._lock)
- self._operations: dict[str, RuntimeOperation] = {}
- self._active_by_workspace: dict[str, str] = {}
- self._idempotency: dict[tuple[str, str], str] = {}
- self._step_log_tails: dict[str, _StepLogTail] = {}
- self._runtime_instance_id = uuid4().hex
- self._workspace_sequences: dict[str, int] = {}
-
- def set_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None:
- with self._lock:
- self._publisher = publisher
-
- def start(
- self,
- *,
- workspace_id: str,
- kind: str,
- origin: str,
- rerun: bool,
- step: str,
- idempotency_key: str,
- runner: Callable[[RuntimeFlowObserver], dict[str, Any]],
- ) -> dict[str, Any]:
- with self._lock:
- if idempotency_key:
- known_id = self._idempotency.get((workspace_id, idempotency_key))
- if known_id is not None:
- return {
- **self._operation_payload(self._operations[known_id]),
- "deduplicated": True,
- }
-
- active_id = self._active_by_workspace.get(workspace_id)
- if active_id is not None:
- active = self._operations[active_id]
- raise RuntimeOperationConflict(
- f"workspace already has an active operation: {active.operation_id}"
- )
-
- operation = RuntimeOperation(
- operation_id=f"operation-{uuid4().hex}",
- run_session_id=uuid4().hex,
- runtime_instance_id=self._runtime_instance_id,
- workspace_id=workspace_id,
- kind=kind,
- origin=origin,
- rerun=rerun,
- step=step,
- idempotency_key=idempotency_key,
- )
- self._operations[operation.operation_id] = operation
- self._active_by_workspace[workspace_id] = operation.operation_id
- if idempotency_key:
- self._idempotency[(workspace_id, idempotency_key)] = operation.operation_id
- queued_event = self._new_event_locked(operation, "operation.queued", {})
-
- self._publish(queued_event)
- thread = threading.Thread(
- target=self._run,
- args=(operation.operation_id, runner),
- name=f"ecc-runtime-{operation.operation_id}",
- daemon=True,
- )
- thread.start()
- return self.operation_status(operation.operation_id)
-
- def operation_status(self, operation_id: str) -> dict[str, Any]:
- with self._lock:
- operation = self._operations.get(operation_id)
- if operation is None:
- raise KeyError(operation_id)
- return self._operation_payload(operation)
-
- def is_active(self, operation_id: str) -> bool:
- with self._lock:
- operation = self._operations.get(operation_id)
- return operation is not None and operation.state not in _TERMINAL_OPERATION_STATES
-
- def workspace_snapshot(self, workspace_id: str) -> dict[str, Any]:
- with self._lock:
- operations = [
- self._operation_payload(operation)
- for operation in self._operations.values()
- if operation.workspace_id == workspace_id
- ]
- return {
- "workspaceId": workspace_id,
- "runtimeInstanceId": self._runtime_instance_id,
- "lastEventId": (
- f"{self._runtime_instance_id}:{workspace_id}:"
- f"{self._workspace_sequences.get(workspace_id, 0)}"
- ),
- "operations": operations,
- }
-
- def acknowledge_step_rendered(
- self,
- operation_id: str,
- event_id: str,
- step_commit_id: str = "",
- workspace_revision: int | None = None,
- ) -> dict[str, Any]:
- with self._render_gate:
- operation = self._operations.get(operation_id)
- if operation is None:
- raise KeyError(operation_id)
- if event_id in operation.acked_event_ids:
- return {
- "accepted": True,
- "duplicate": True,
- "operationId": operation_id,
- "eventId": event_id,
- }
- if operation.awaiting_event_id != event_id:
- return {
- "accepted": False,
- "duplicate": False,
- "operationId": operation_id,
- "eventId": event_id,
- }
- if step_commit_id and operation.awaiting_step_commit_id != step_commit_id:
- return {
- "accepted": False,
- "duplicate": False,
- "operationId": operation_id,
- "eventId": event_id,
- }
- if (
- workspace_revision is not None
- and workspace_revision != operation.workspace_revision
- ):
- return {
- "accepted": False,
- "duplicate": False,
- "operationId": operation_id,
- "eventId": event_id,
- }
- operation.acked_event_ids.add(event_id)
- operation.awaiting_event_id = None
- operation.awaiting_event = None
- operation.awaiting_step_commit_id = None
- operation.render_sync_state = "idle"
- operation.render_wait_started_at = None
- operation.last_render_ack_at = time.time()
- operation.updated_at = time.time()
- self._render_gate.notify_all()
- return {
- "accepted": True,
- "duplicate": False,
- "operationId": operation_id,
- "eventId": event_id,
- }
-
- def request_cancel(self, operation_id: str) -> dict[str, Any]:
- with self._render_gate:
- operation = self._operations.get(operation_id)
- if operation is None:
- raise KeyError(operation_id)
- if operation.state in _TERMINAL_OPERATION_STATES:
- return {"accepted": False, "operationId": operation_id, "state": operation.state}
- operation.cancel_requested = True
- operation.updated_at = time.time()
- event = self._new_event_locked(operation, "operation.cancel_requested", {})
- self._render_gate.notify_all()
- self._publish(event)
- return {"accepted": True, "operationId": operation_id, "state": operation.state}
-
- def shutdown_barrier(self) -> dict[str, Any] | None:
- with self._lock:
- for operation_id in self._active_by_workspace.values():
- operation = self._operations[operation_id]
- return {
- "operationId": operation.operation_id,
- "workspaceId": operation.workspace_id,
- "state": operation.state,
- "step": operation.current_step,
- "interruptibility": operation.interruptibility,
- "safeToStop": bool(operation.awaiting_event_id),
- "cancelRequested": operation.cancel_requested,
- }
- return None
-
- def _run(
- self,
- operation_id: str,
- runner: Callable[[RuntimeFlowObserver], dict[str, Any]],
- ) -> None:
- with self._lock:
- operation = self._operations[operation_id]
- operation.state = "running"
- operation.updated_at = time.time()
- started_event = self._new_event_locked(operation, "operation.started", {})
- observer = RuntimeFlowObserver(self, operation_id)
- try:
- self._publish(started_event)
- try:
- result = runner(observer)
- with self._lock:
- operation = self._operations[operation_id]
- 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,
- "operation.completed",
- {"result": result},
- )
- except RuntimeOperationCancelled as exc:
- with self._lock:
- operation = self._operations[operation_id]
- if operation.error is not None:
- operation.state = "failed"
- event_type = "operation.failed"
- else:
- operation.state = "cancelled"
- operation.error = {
- "message": str(exc),
- "code": "cancelled",
- }
- event_type = "operation.cancelled"
- operation.updated_at = time.time()
- event = self._new_event_locked(
- operation,
- event_type,
- {"error": operation.error},
- )
- except Exception as exc:
- with self._lock:
- operation = self._operations[operation_id]
- 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": "command_failed",
- }
- event_type = "operation.failed"
- operation.updated_at = time.time()
- payload = {"error": operation.error}
- if operation.error:
- payload.update(
- {
- key: operation.error[key]
- for key in ("step", "tool", "logFile")
- if key in operation.error
- }
- )
- event = self._new_event_locked(operation, event_type, payload)
- self._publish(event)
- finally:
- try:
- self._stop_step_log_tail(operation_id)
- finally:
- with self._lock:
- self._active_by_workspace.pop(
- self._operations[operation_id].workspace_id,
- None,
- )
-
- def step_started(self, operation_id: str, workspace_step: Any) -> None:
- self._stop_step_log_tail(operation_id)
- with self._lock:
- operation = self._operations[operation_id]
- operation.current_step = str(getattr(workspace_step, "name", ""))
- operation.current_tool = str(getattr(workspace_step, "tool", ""))
- operation.updated_at = time.time()
- event = self._new_event_locked(
- operation,
- "step.started",
- {
- "step": operation.current_step,
- "tool": operation.current_tool,
- "state": "Ongoing",
- },
- )
- log_tail = _step_log_tail_for(
- operation_id,
- getattr(workspace_step, "log", None),
- operation.current_step,
- operation.current_tool,
- )
- if log_tail is not None:
- self._step_log_tails[operation_id] = log_tail
- self._publish(event)
- if log_tail is not None:
- thread = threading.Thread(
- target=self._tail_step_log,
- args=(log_tail,),
- name=f"ecc-runtime-log-{operation_id}",
- daemon=True,
- )
- log_tail.thread = thread
- thread.start()
-
- def rerun_prepared(
- self,
- operation_id: str,
- *,
- affected_steps: list[str],
- scope: str,
- target_step: str = "",
- ) -> None:
- """Publish the idempotent GUI reset boundary before a rerun starts."""
- with self._lock:
- operation = self._operations[operation_id]
- operation.updated_at = time.time()
- event = self._new_event_locked(
- operation,
- "operation.rerun_prepared",
- {
- "affectedSteps": affected_steps,
- "scope": scope,
- "targetStep": target_step,
- },
- )
- self._publish(event)
-
- def step_completed(
- self,
- operation_id: str,
- workspace_step: Any,
- state: Any,
- error: str | None = None,
- ) -> None:
- self._stop_step_log_tail(operation_id)
- state_value = str(getattr(state, "value", state))
- final_log = _read_final_log(getattr(workspace_step, "log", None))
- with self._render_gate:
- operation = self._operations[operation_id]
- operation.current_step = str(getattr(workspace_step, "name", ""))
- operation.current_tool = str(getattr(workspace_step, "tool", ""))
- operation.updated_at = time.time()
- payload: dict[str, Any] = {
- "finalLog": final_log,
- "step": operation.current_step,
- "tool": operation.current_tool,
- "state": state_value,
- }
- if error:
- log_file = str(getattr(getattr(workspace_step, "log", None), "file", "") or "")
- operation.error = {
- "code": "tool_failed",
- "message": error,
- "step": operation.current_step,
- "tool": operation.current_tool,
- "logFile": log_file,
- }
- payload["error"] = operation.error
- payload["logFile"] = log_file
- event = self._new_event_locked(operation, "step.completed", payload)
- if state_value == "Success":
- operation.workspace_revision += 1
- step_commit_id = f"{operation.operation_id}:step:{operation.workspace_revision}"
- payload["stepCommitId"] = step_commit_id
- payload["workspaceRevision"] = operation.workspace_revision
- if not operation.render_sync_degraded:
- operation.awaiting_event_id = event["eventId"]
- operation.awaiting_event = event
- operation.awaiting_step_commit_id = step_commit_id
- operation.render_sync_state = "waiting_for_gui_sync"
- operation.render_retry_count = 0
- operation.render_wait_started_at = time.monotonic()
- operation.state = "waiting_for_gui_sync"
- self._publish(event)
-
- def subflow_stage(
- self,
- operation_id: str,
- workspace_step: Any,
- subflow_step: dict[str, Any],
- ) -> None:
- """Publish a saved inner-flow state without waiting for a render ACK."""
- with self._lock:
- operation = self._operations[operation_id]
- step = str(getattr(workspace_step, "name", ""))
- tool = str(getattr(workspace_step, "tool", ""))
- event = self._new_event_locked(
- operation,
- "subflow.stage",
- {
- "peakMemory": subflow_step.get("peak memory (mb)", 0),
- "runtime": str(subflow_step.get("runtime", "")),
- "state": str(subflow_step.get("state", "Unstart")),
- "step": step,
- "subflowStep": str(subflow_step.get("name", "")),
- "tool": tool,
- },
- )
- self._publish(event)
-
- def step_skipped(self, operation_id: str, workspace_step: Any) -> None:
- self._stop_step_log_tail(operation_id)
- with self._lock:
- operation = self._operations[operation_id]
- operation.current_step = str(getattr(workspace_step, "name", ""))
- operation.current_tool = str(getattr(workspace_step, "tool", ""))
- operation.updated_at = time.time()
- event = self._new_event_locked(
- operation,
- "step.completed",
- {
- "step": operation.current_step,
- "tool": operation.current_tool,
- "state": "Skipped",
- },
- )
- self._publish(event)
-
- def wait_for_step_rendered(self, operation_id: str) -> bool:
- while True:
- degraded_event: dict[str, Any] | None = None
- replay_event: dict[str, Any] | None = None
- pause_event: dict[str, Any] | None = None
- with self._render_gate:
- operation = self._operations[operation_id]
- if operation.cancel_requested:
- return False
- if not operation.awaiting_event_id:
- if operation.state in {
- "waiting_for_gui_sync",
- "paused_for_gui_recovery",
- }:
- operation.state = "running"
- operation.updated_at = time.time()
- return True
-
- started_at = operation.render_wait_started_at or time.monotonic()
- elapsed = time.monotonic() - started_at
- if elapsed >= _RENDER_ACK_ABORT_SECONDS:
- awaiting_event_id = operation.awaiting_event_id
- awaiting_step_commit_id = operation.awaiting_step_commit_id
- operation.awaiting_event_id = None
- operation.awaiting_event = None
- operation.awaiting_step_commit_id = None
- operation.render_sync_state = "gui_sync_degraded"
- operation.render_sync_degraded = True
- operation.state = "running"
- operation.updated_at = time.time()
- degraded_event = self._new_event_locked(
- operation,
- "operation.gui_sync_degraded",
- {
- "eventId": awaiting_event_id,
- "stepCommitId": awaiting_step_commit_id,
- "workspaceRevision": operation.workspace_revision,
- },
- )
- elif (
- elapsed >= _RENDER_ACK_PAUSE_SECONDS
- and operation.render_sync_state != "paused_for_gui_recovery"
- ):
- operation.render_sync_state = "paused_for_gui_recovery"
- operation.state = "paused_for_gui_recovery"
- operation.updated_at = time.time()
- pause_event = self._new_event_locked(
- operation,
- "operation.gui_sync_paused",
- {
- "eventId": operation.awaiting_event_id,
- "stepCommitId": operation.awaiting_step_commit_id,
- "workspaceRevision": operation.workspace_revision,
- },
- )
-
- if degraded_event is None:
- operation.render_retry_count += 1
- if operation.awaiting_event is not None:
- replay_event = {
- **operation.awaiting_event,
- "payload": {
- **operation.awaiting_event["payload"],
- "replayed": True,
- "retryCount": operation.render_retry_count,
- },
- }
- self._render_gate.wait(timeout=_RENDER_ACK_RETRY_SECONDS)
-
- if degraded_event is not None:
- self._publish(degraded_event)
- return True
- if pause_event is not None:
- self._publish(pause_event)
- if replay_event is not None:
- self._publish(replay_event)
-
- def _tail_step_log(self, log_tail: _StepLogTail) -> None:
- while not log_tail.stopped.is_set():
- self._publish_step_log_delta(log_tail)
- log_tail.stopped.wait(_LOG_POLL_INTERVAL_SECONDS)
-
- def _publish_step_log_delta(self, log_tail: _StepLogTail) -> None:
- try:
- size = log_tail.path.stat().st_size
- if size < log_tail.cursor:
- # A rerun may truncate or replace a log file. The renderer treats
- # this as a new bounded stream for the same step attempt.
- log_tail.cursor = 0
- if size <= log_tail.cursor:
- return
- with log_tail.path.open("rb") as log_file:
- log_file.seek(log_tail.cursor)
- chunk = log_file.read(_MAX_LOG_CHUNK_BYTES)
- except OSError:
- return
-
- if not chunk:
- return
- log_tail.cursor += len(chunk)
- text = chunk.decode("utf-8", errors="replace")
- with self._lock:
- if self._step_log_tails.get(log_tail.operation_id) is not log_tail:
- return
- operation = self._operations.get(log_tail.operation_id)
- if operation is None:
- return
- event = self._new_event_locked(
- operation,
- "step.log",
- {
- "chunk": text,
- "cursor": log_tail.cursor,
- "step": log_tail.step,
- "tool": log_tail.tool,
- },
- )
- self._publish(event)
-
- def _stop_step_log_tail(self, operation_id: str) -> None:
- with self._lock:
- log_tail = self._step_log_tails.pop(operation_id, None)
- if log_tail is None:
- return
- log_tail.stopped.set()
- if log_tail.thread is not None and log_tail.thread is not threading.current_thread():
- log_tail.thread.join(timeout=_LOG_POLL_INTERVAL_SECONDS + 0.25)
-
- def _new_event_locked(
- self,
- operation: RuntimeOperation,
- event_type: str,
- payload: dict[str, Any],
- ) -> dict[str, Any]:
- sequence = self._workspace_sequences.get(operation.workspace_id, 0) + 1
- self._workspace_sequences[operation.workspace_id] = sequence
- operation.sequence = sequence
- return {
- "eventId": f"{self._runtime_instance_id}:{operation.operation_id}:{sequence}",
- "runtimeInstanceId": self._runtime_instance_id,
- "runSessionId": operation.run_session_id,
- "sequence": sequence,
- "type": event_type,
- "workspaceId": operation.workspace_id,
- "operationId": operation.operation_id,
- "origin": operation.origin,
- "kind": operation.kind,
- "rerun": operation.rerun,
- "timestamp": time.time(),
- "payload": payload,
- }
-
- @staticmethod
- def _operation_payload(operation: RuntimeOperation) -> dict[str, Any]:
- return {
- "operationId": operation.operation_id,
- "runSessionId": operation.run_session_id,
- "runtimeInstanceId": operation.runtime_instance_id,
- "workspaceId": operation.workspace_id,
- "kind": operation.kind,
- "origin": operation.origin,
- "rerun": operation.rerun,
- "step": operation.step,
- "state": operation.state,
- "currentStep": operation.current_step,
- "currentTool": operation.current_tool,
- "error": operation.error,
- "result": operation.result,
- "awaitingEventId": operation.awaiting_event_id,
- "awaitingStepCommitId": operation.awaiting_step_commit_id,
- "workspaceRevision": operation.workspace_revision,
- "renderSyncState": operation.render_sync_state,
- "renderRetryCount": operation.render_retry_count,
- "lastRenderAckAt": operation.last_render_ack_at,
- "cancelRequested": operation.cancel_requested,
- "interruptibility": operation.interruptibility,
- "safeToStop": bool(operation.awaiting_event_id),
- "shutdownBarrier": operation.state not in _TERMINAL_OPERATION_STATES,
- "createdAt": operation.created_at,
- "updatedAt": operation.updated_at,
- }
-
- def _publish(self, event: dict[str, Any]) -> None:
- publisher = self._publisher
- if publisher is not None:
- publisher(event)
-
-
-class RuntimeFlowObserver:
- def __init__(self, manager: RuntimeOperationManager, operation_id: str):
- self._manager = manager
- self._operation_id = operation_id
-
- @property
- def runtime_operation(self) -> dict[str, Any]:
- return {
- "schema": 1,
- "operation_id": self._operation_id,
- "runtime_instance_id": self._manager._runtime_instance_id,
- }
-
- def on_step_started(self, workspace_step: Any) -> None:
- self._manager.step_started(self._operation_id, workspace_step)
-
- def on_rerun_prepared(
- self,
- *,
- affected_steps: list[str],
- scope: str,
- target_step: str = "",
- ) -> None:
- self._manager.rerun_prepared(
- self._operation_id,
- affected_steps=affected_steps,
- scope=scope,
- target_step=target_step,
- )
-
- def on_step_completed(
- self,
- workspace_step: Any,
- state: Any,
- error: str | None = None,
- ) -> None:
- self._manager.step_completed(self._operation_id, workspace_step, state, error)
-
- def on_subflow_stage(self, workspace_step: Any, subflow_step: dict[str, Any]) -> None:
- self._manager.subflow_stage(self._operation_id, workspace_step, subflow_step)
-
- def on_step_skipped(self, workspace_step: Any) -> None:
- self._manager.step_skipped(self._operation_id, workspace_step)
-
- def wait_for_step_rendered(self, _workspace_step: Any, _state: Any) -> bool:
- return self._manager.wait_for_step_rendered(self._operation_id)
-
-
-def _read_final_log(log: Any) -> str:
- path = getattr(log, "file", None)
- if not path:
- return ""
- try:
- with Path(path).open("rb") as log_file:
- log_file.seek(0, 2)
- size = log_file.tell()
- log_file.seek(max(0, size - _MAX_FINAL_LOG_BYTES))
- return log_file.read().decode("utf-8", errors="replace")
- except OSError:
- return ""
-
-
-def _step_log_tail_for(
- operation_id: str,
- log: Any,
- step: str,
- tool: str,
-) -> _StepLogTail | None:
- path = getattr(log, "file", None)
- if not path:
- return None
- log_path = Path(path)
- try:
- cursor = log_path.stat().st_size
- except OSError:
- cursor = 0
- return _StepLogTail(
- operation_id=operation_id,
- path=log_path,
- step=step,
- tool=tool,
- cursor=cursor,
- )
diff --git a/chipcompiler/runtime/recovery.py b/chipcompiler/runtime/recovery.py
deleted file mode 100644
index ca85e85c..00000000
--- a/chipcompiler/runtime/recovery.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from copy import deepcopy
-from pathlib import Path
-from typing import Any
-
-from chipcompiler.data import StateEnum, Workspace
-from chipcompiler.runtime.operations import RuntimeOperationManager
-from chipcompiler.utility import json_write
-
-
-def recover_interrupted_operation(
- workspace: Workspace,
- operations: RuntimeOperationManager,
- operation_id: str = "",
-) -> dict[str, list[dict[str, str]]]:
- flow_data = deepcopy(workspace.flow.data)
- recovered = []
- for step in flow_data.get("steps", []):
- marker = step.get("info", {}).get("runtime_operation")
- if (
- step.get("state") != "Ongoing"
- or not isinstance(marker, dict)
- or marker.get("schema") != 1
- or not isinstance(marker.get("operation_id"), str)
- or not marker["operation_id"]
- or not isinstance(marker.get("runtime_instance_id"), str)
- or not marker["runtime_instance_id"]
- or not isinstance(marker.get("started_at"), (int, float))
- or (operation_id and marker.get("operation_id") != operation_id)
- or operations.is_active(str(marker["operation_id"]))
- ):
- continue
- log_file = _step_log_file(workspace.directory, step)
- step["state"] = StateEnum.Imcomplete.value
- step["info"].pop("runtime_operation", None)
- recovered.append(
- {
- "step": str(step.get("name", "")),
- "tool": str(step.get("tool", "")),
- "operationId": str(marker["operation_id"]),
- "logFile": log_file,
- }
- )
- if recovered:
- if not json_write(workspace.flow.path, flow_data):
- raise OSError(f"failed to save recovered flow state: {workspace.flow.path}")
- workspace.flow.data = flow_data
- return {"recovered": recovered}
-
-
-def _step_log_file(directory: str | Path, step: dict[str, Any]) -> str:
- name = str(step.get("name", ""))
- tool = str(step.get("tool", ""))
- return str(Path(directory) / f"{name}_{tool}" / "log" / f"{name}.log")
diff --git a/chipcompiler/runtime/requests.py b/chipcompiler/runtime/requests.py
deleted file mode 100644
index 8eb6116d..00000000
--- a/chipcompiler/runtime/requests.py
+++ /dev/null
@@ -1,259 +0,0 @@
-from dataclasses import MISSING, dataclass, fields
-from typing import Any
-
-
-@dataclass(frozen=True)
-class WorkspaceCreateRequest:
- directory: str
- pdk: str = ""
- pdk_root: str = ""
- pdk_json: Any = None
- parameters: dict[str, Any] | None = None
- origin_def: str = ""
- origin_verilog: str = ""
- filelist: str = ""
- rtl_list: list[str] | None = None
- sdc: str = ""
- flow_config: dict[str, Any] | None = None
-
-
-@dataclass(frozen=True)
-class WorkspaceOpenRequest:
- directory: str
-
-
-@dataclass(frozen=True)
-class WorkspaceIdRequest:
- workspace_id: str
-
-
-@dataclass(frozen=True)
-class WorkspaceRecoverInterruptedRequest:
- workspace_id: str
- operation_id: str = ""
-
-
-@dataclass(frozen=True)
-class WorkspaceCloseRequest:
- workspace_id: str
-
-
-@dataclass(frozen=True)
-class WorkspaceSyncConfigRequest:
- workspace_id: str
- config_path: str
-
-
-@dataclass(frozen=True)
-class WorkspaceExportSignoffRequest:
- workspace_id: str
- output_path: str
- additional_files: list[dict[str, str]] | None = None
-
-
-@dataclass(frozen=True)
-class WorkspaceInspectSignoffRequest:
- workspace_id: str
-
-
-@dataclass(frozen=True)
-class WorkspaceInfoRequest:
- workspace_id: str
- step: str
- info_id: str
-
-
-@dataclass(frozen=True)
-class FlowRunRequest:
- workspace_id: str
- rerun: bool = False
-
-
-@dataclass(frozen=True)
-class FlowRunStepRequest:
- workspace_id: str
- step: str
- rerun: bool = False
-
-
-@dataclass(frozen=True)
-class OperationStartFlowRequest:
- workspace_id: str
- rerun: bool = False
- origin: str = "gui"
- idempotency_key: str = ""
-
-
-@dataclass(frozen=True)
-class OperationStartStepRequest:
- workspace_id: str
- step: str
- rerun: bool = False
- reset_dependents: bool = False
- origin: str = "gui"
- idempotency_key: str = ""
-
-
-@dataclass(frozen=True)
-class OperationIdRequest:
- operation_id: str
-
-
-@dataclass(frozen=True)
-class OperationAckStepRenderedRequest:
- operation_id: str
- event_id: str
- step_commit_id: str = ""
- workspace_revision: int | None = None
-
-
-@dataclass(frozen=True)
-class DbEnsureRequest:
- workspace_id: str
- step: str = ""
-
-
-@dataclass(frozen=True)
-class DbReleaseRequest:
- workspace_id: str
-
-
-@dataclass(frozen=True)
-class LayoutEditBeginRequest:
- workspace_id: str
- step: str
- expected_source_fingerprint: str = ""
-
-
-@dataclass(frozen=True)
-class LayoutEditApplyRequest:
- edit_session_id: str
- command_id: str
- base_revision: int
- operation: dict[str, Any]
-
-
-@dataclass(frozen=True)
-class LayoutEditSaveRequest:
- edit_session_id: str
- expected_revision: int
-
-
-@dataclass(frozen=True)
-class LayoutEditDiscardRequest:
- edit_session_id: str
-
-
-@dataclass(frozen=True)
-class FloorplanEditInspectRequest:
- edit_session_id: str
-
-
-@dataclass(frozen=True)
-class FloorplanEditRunAutoRequest:
- edit_session_id: str
- command_id: str
- base_revision: int
- request: dict[str, Any]
-
-
-@dataclass(frozen=True)
-class FloorplanEditValidateRequest:
- edit_session_id: str
- scope: str = "all"
-
-
-class RequestValidationError(ValueError):
- def __init__(self, reason: str):
- super().__init__(reason)
- self.reason = reason
-
-
-FIELD_ALIASES = {
- "flowConfig": "flow_config",
- "pdkRoot": "pdk_root",
- "pdkJson": "pdk_json",
- "originDef": "origin_def",
- "originVerilog": "origin_verilog",
- "paramJson": "parameters",
- "rtlList": "rtl_list",
- "workspaceId": "workspace_id",
- "operationId": "operation_id",
- "eventId": "event_id",
- "stepCommitId": "step_commit_id",
- "workspaceRevision": "workspace_revision",
- "idempotencyKey": "idempotency_key",
- "resetDependents": "reset_dependents",
- "configPath": "config_path",
- "outputPath": "output_path",
- "infoId": "info_id",
- "editSessionId": "edit_session_id",
- "commandId": "command_id",
- "baseRevision": "base_revision",
- "expectedRevision": "expected_revision",
- "expectedSourceFingerprint": "expected_source_fingerprint",
- "id": "info_id",
- "additionalFiles": "additional_files",
-}
-
-
-def parse_request_model(model: type, params: object):
- if not isinstance(params, dict):
- raise RequestValidationError("params must be an object")
-
- normalized = _normalize_fields(params)
- model_fields = {field.name: field for field in fields(model)}
- for key in normalized:
- if key not in model_fields:
- raise RequestValidationError(f"unknown field: {key}")
-
- values: dict[str, Any] = {}
- for field in fields(model):
- required = field.default is MISSING and field.default_factory is MISSING
-
- if field.name in normalized:
- values[field.name] = normalized[field.name]
- elif not required:
- values[field.name] = field.default
- else:
- raise RequestValidationError(f"missing required field: {field.name}")
-
- if required and _is_missing(values[field.name]):
- raise RequestValidationError(f"missing required field: {field.name}")
-
- if field.name in {"rerun", "reset_dependents"} and not isinstance(values[field.name], bool):
- raise RequestValidationError(f"{field.name} must be a boolean")
- if field.name == "additional_files":
- _validate_additional_files(values[field.name])
-
- return model(**values)
-
-
-def _normalize_fields(params: dict) -> dict[str, Any]:
- normalized: dict[str, Any] = {}
- for key, value in params.items():
- normalized_key = FIELD_ALIASES.get(str(key), str(key))
- if normalized_key in normalized:
- raise RequestValidationError(f"duplicate field: {normalized_key}")
- normalized[normalized_key] = value
- return normalized
-
-
-def _is_missing(value: object) -> bool:
- return value is None or (isinstance(value, str) and not value.strip())
-
-
-def _validate_additional_files(value: object) -> None:
- if value is None:
- return
- if not isinstance(value, list):
- raise RequestValidationError("additional_files must be a list")
- for index, item in enumerate(value):
- if not isinstance(item, dict):
- raise RequestValidationError(f"additional_files[{index}] must be an object")
- if not isinstance(item.get("archivePath"), str) or not item["archivePath"]:
- raise RequestValidationError(
- f"additional_files[{index}].archivePath must be a non-empty string"
- )
- if not isinstance(item.get("content"), str):
- raise RequestValidationError(f"additional_files[{index}].content must be a string")
diff --git a/chipcompiler/runtime/rpc_dispatch.py b/chipcompiler/runtime/rpc_dispatch.py
deleted file mode 100644
index 89ad3f91..00000000
--- a/chipcompiler/runtime/rpc_dispatch.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from collections.abc import Callable
-from functools import wraps
-from typing import Any
-
-from jsonrpcserver import Success, dispatch
-from oslash.either import Left, Right
-
-from chipcompiler.runtime.events import redirect_stdout_to_stderr
-
-JsonRpcHandler = Callable[..., Any]
-
-
-class RpcDispatcher:
- def __init__(self):
- self._methods: dict[str, JsonRpcHandler] = {}
-
- def add_method(self, name: str, handler: JsonRpcHandler) -> None:
- self._methods[name] = self._wrap_handler(handler)
-
- def method(self, name: str) -> Callable[[JsonRpcHandler], JsonRpcHandler]:
- def decorator(handler: JsonRpcHandler) -> JsonRpcHandler:
- self.add_method(name, handler)
- return handler
-
- return decorator
-
- def dispatch(self, payload: bytes | str) -> str:
- request_text = payload.decode("utf-8") if isinstance(payload, bytes) else payload
- return dispatch(request_text, methods=self._methods)
-
- def _wrap_handler(self, handler: JsonRpcHandler) -> JsonRpcHandler:
- @wraps(handler)
- def wrapped(*args: Any, **kwargs: Any):
- with redirect_stdout_to_stderr():
- result = handler(*args, **kwargs)
- if isinstance(result, Left | Right):
- return result
- return Success(result)
-
- return wrapped
diff --git a/chipcompiler/runtime/server.py b/chipcompiler/runtime/server.py
deleted file mode 100644
index 70167ea3..00000000
--- a/chipcompiler/runtime/server.py
+++ /dev/null
@@ -1,138 +0,0 @@
-from collections.abc import Callable
-
-from jsonrpcserver import Error
-
-import chipcompiler
-from chipcompiler.runtime import methods
-from chipcompiler.runtime.requests import RequestValidationError, parse_request_model
-from chipcompiler.runtime.rpc_dispatch import RpcDispatcher
-from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi
-
-PROTOCOL_VERSION = 1
-BASE_CAPABILITIES = (
- "rpc.hello",
- "rpc.ping",
- "rpc.shutdown",
- "runtime.v2",
- "operation.events",
-)
-
-ERROR_CODES = {
- "workspace_session_not_found": -32010,
- "command_failed": -32020,
- "invalid_request": -32602,
-}
-
-
-class RuntimeServer:
- def __init__(
- self,
- api: WorkspaceRuntimeApi | None = None,
- *,
- persistent_db_enabled: bool = False,
- ):
- self.persistent_db_enabled = persistent_db_enabled
- self.dispatcher = RpcDispatcher()
- self.api = api or WorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled)
- self.should_exit = False
- self._notification_sink: Callable[[str, dict], None] | None = None
- set_event_publisher = getattr(self.api, "set_event_publisher", None)
- if callable(set_event_publisher):
- set_event_publisher(self._publish_runtime_event)
- self._register_base_methods()
- self._register_runtime_methods()
-
- @property
- def capabilities(self) -> tuple[str, ...]:
- return (
- *BASE_CAPABILITIES,
- *methods.runtime_method_names(
- persistent_db_enabled=self.persistent_db_enabled,
- ),
- )
-
- def dispatch(self, payload: bytes | str) -> str:
- return self.dispatcher.dispatch(payload)
-
- def set_notification_sink(self, sink: Callable[[str, dict], None] | None) -> None:
- self._notification_sink = sink
-
- def _publish_runtime_event(self, event: dict) -> None:
- sink = self._notification_sink
- if sink is not None:
- sink("runtime.event", event)
-
- def _register_base_methods(self) -> None:
- self.dispatcher.add_method("rpc.hello", self._hello)
- self.dispatcher.add_method("rpc.ping", self._ping)
- self.dispatcher.add_method("rpc.shutdown", self._shutdown)
-
- def _hello(self, version: int):
- if version != PROTOCOL_VERSION:
- return Error(
- -32001,
- "unsupported_version",
- {"supportedVersion": PROTOCOL_VERSION, "requestedVersion": version},
- )
- return {
- "version": PROTOCOL_VERSION,
- "eccVersion": getattr(chipcompiler, "__version__", "unknown"),
- "capabilities": list(self.capabilities),
- }
-
- def _ping(self) -> dict:
- return {"ok": True}
-
- def _shutdown(self) -> dict:
- operations = getattr(self.api, "operations", None)
- shutdown_barrier = getattr(operations, "shutdown_barrier", None)
- barrier = shutdown_barrier() if callable(shutdown_barrier) else None
- if barrier is not None:
- return {"ok": False, "deferred": True, "shutdownBarrier": barrier}
- self.should_exit = True
- sessions = getattr(self.api, "sessions", None)
- if sessions is not None and hasattr(sessions, "close_all"):
- sessions.close_all()
- return {"ok": True}
-
- def _register_runtime_methods(self) -> None:
- for spec in methods.runtime_methods(
- persistent_db_enabled=self.persistent_db_enabled,
- ):
- api_method = getattr(self.api, spec.handler_name, None)
- if not callable(api_method):
- raise TypeError(
- f"runtime method {spec.method_name} handler {spec.handler_name} is not callable"
- )
- self.dispatcher.add_method(
- spec.method_name,
- self._runtime_method_handler(spec, api_method),
- )
-
- def _runtime_method_handler(self, spec, api_method):
- def handler(**params):
- try:
- request = parse_request_model(spec.request_model, params)
- except RequestValidationError as exc:
- return Error(
- -32602,
- "invalid_request",
- {"message": exc.reason},
- )
-
- try:
- return api_method(request)
- except RuntimeApiError as exc:
- return Error(
- ERROR_CODES.get(exc.code, -32000),
- exc.code,
- {"message": exc.message, **exc.data},
- )
- except Exception as exc:
- return Error(
- ERROR_CODES["command_failed"],
- "command_failed",
- {"message": str(exc)},
- )
-
- return handler
diff --git a/chipcompiler/runtime/sessions.py b/chipcompiler/runtime/sessions.py
deleted file mode 100644
index c975ac10..00000000
--- a/chipcompiler/runtime/sessions.py
+++ /dev/null
@@ -1,151 +0,0 @@
-import shutil
-import threading
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-
-
-@dataclass
-class WorkspaceSession:
- workspace_id: str
- directory: Path
- workspace: Any
- db_handle: Any = None
- layout_edit_session: "LayoutEditSession | None" = None
- mutation_lock: threading.Lock = field(default_factory=threading.Lock)
-
-
-@dataclass
-class LayoutEditSession:
- edit_session_id: str
- workspace_id: str
- step_name: str
- workspace_step: Any
- db_handle: Any
- source_kind: str
- source_paths: tuple[Path, ...]
- source_fingerprint: str
- geometry_output_dir: Path
- revision: int = 0
- geometry_revision: int = 0
- dirty: bool = False
- command_results: dict[str, dict[str, Any]] = field(default_factory=dict)
- floorplan_plan: dict[str, Any] = field(default_factory=dict)
- pdn_plan: dict[str, Any] = field(default_factory=dict)
- config_patch: dict[str, Any] = field(default_factory=dict)
- parameters_patch: dict[str, Any] = field(default_factory=dict)
- requires_verilog: bool = False
- used_floorplan_editor: bool = False
- validation_diagnostics: list[dict[str, Any]] = field(default_factory=list)
-
-
-class WorkspaceSessionNotFound(KeyError):
- pass
-
-
-def _close_db_handle(db_handle: Any) -> None:
- close = getattr(db_handle, "close", None)
- if callable(close):
- close()
-
-
-class WorkspaceSessionRegistry:
- def __init__(self, db_releaser: Callable[[Any], None] | None = _close_db_handle):
- self._next_id = 1
- self._sessions: dict[str, WorkspaceSession] = {}
- self._sessions_by_directory: dict[Path, str] = {}
- self._db_releaser = db_releaser
- self._lock = threading.Lock()
-
- def create_session(self, directory: str | Path, *, workspace: Any) -> WorkspaceSession:
- resolved_directory = Path(directory).resolve()
- with self._lock:
- existing_id = self._sessions_by_directory.get(resolved_directory)
- if existing_id is not None:
- self._remove_session(existing_id)
- return self._create_session(resolved_directory, workspace=workspace)
-
- def open_session(self, directory: str | Path, *, workspace: Any) -> WorkspaceSession:
- resolved_directory = Path(directory).resolve()
- with self._lock:
- existing_id = self._sessions_by_directory.get(resolved_directory)
- if existing_id is not None:
- return self._sessions[existing_id]
-
- return self._create_session(resolved_directory, workspace=workspace)
-
- def get_session(self, workspace_id: str) -> WorkspaceSession:
- try:
- return self._sessions[workspace_id]
- except KeyError as exc:
- raise WorkspaceSessionNotFound(workspace_id) from exc
-
- def close_session(self, workspace_id: str) -> None:
- with self._lock:
- self._remove_session(workspace_id)
-
- def close_all(self) -> None:
- with self._lock:
- for session in self._sessions.values():
- self._release_session_db(session)
- self._release_layout_edit_session(session)
- self._sessions.clear()
- self._sessions_by_directory.clear()
-
- def release_session_db(self, session: WorkspaceSession) -> bool:
- return self._release_session_db(session)
-
- def _create_session(self, directory: Path, *, workspace: Any) -> WorkspaceSession:
- workspace_id = f"workspace-{self._next_id}"
- self._next_id += 1
- session = WorkspaceSession(
- workspace_id=workspace_id,
- directory=directory,
- workspace=workspace,
- )
- self._sessions[workspace_id] = session
- self._sessions_by_directory[directory] = workspace_id
- return session
-
- def _remove_session(self, workspace_id: str) -> None:
- session = self._sessions.pop(workspace_id, None)
- if session is None:
- raise WorkspaceSessionNotFound(workspace_id)
- self._release_session_db(session)
- self._release_layout_edit_session(session)
- self._sessions_by_directory.pop(session.directory, None)
-
- def _release_session_db(self, session: WorkspaceSession) -> bool:
- db_handle = session.db_handle
- if db_handle is None:
- return False
-
- session.db_handle = None
- if self._db_releaser is not None:
- self._db_releaser(db_handle)
- return True
-
- def release_layout_edit_session(self, session: WorkspaceSession) -> bool:
- return self._release_layout_edit_session(session)
-
- def _release_layout_edit_session(self, session: WorkspaceSession) -> bool:
- layout_edit_session = session.layout_edit_session
- if layout_edit_session is None:
- return False
-
- session.layout_edit_session = None
- _reset_layout_edit_geometry_session(layout_edit_session.db_handle)
- if self._db_releaser is not None:
- self._db_releaser(layout_edit_session.db_handle)
- shutil.rmtree(layout_edit_session.geometry_output_dir.parent, ignore_errors=True)
- return True
-
-
-def _reset_layout_edit_geometry_session(db_handle: Any) -> None:
- module = getattr(db_handle, "engine", None)
- if module is None:
- module = getattr(db_handle, "ecc_module", None)
- reset_geometry_session = getattr(module, "reset_geometry_session", None)
- if callable(reset_geometry_session):
- reset_geometry_session()
diff --git a/chipcompiler/runtime/signoff_export.py b/chipcompiler/runtime/signoff_export.py
deleted file mode 100644
index 8206a964..00000000
--- a/chipcompiler/runtime/signoff_export.py
+++ /dev/null
@@ -1,285 +0,0 @@
-import os
-import shutil
-import tempfile
-from pathlib import Path
-
-from chipcompiler.engine import EngineFlow, SignoffPackageOptions
-from chipcompiler.runtime.workspace_api import RuntimeApiError
-from chipcompiler.utility import json_read
-
-_REVIEW_GROUPS = (
- ("initial", "Initial"),
- ("config", "Config"),
- ("harden", "Harden"),
- ("final_design", "Final Design"),
- ("sta", "STA"),
- ("spef", "SPEF"),
- ("reports", "Reports"),
-)
-
-
-def inspect_signoff_package(workspace) -> dict:
- """Refresh current outputs, then render the home checklist contract only."""
- EngineFlow(workspace).collect_signoff_package(
- SignoffPackageOptions(archive=False, materialize=False, refresh_analysis=True)
- )
- checklist_path = Path(workspace.directory) / "home" / "checklist.json"
- checklist_data = json_read(checklist_path)
- if (
- not isinstance(checklist_data, dict)
- or checklist_data.get("schema_version") != 3
- or checklist_data.get("kind") != "signoff_checklist"
- ):
- return _unavailable_review()
-
- groups = {
- group_id: {
- "id": group_id,
- "label": label,
- "available": 0,
- "expected": 0,
- "blocked_details": [],
- "attention_details": [],
- }
- for group_id, label in _REVIEW_GROUPS
- }
- for item in checklist_data.get("checklist", []):
- if not isinstance(item, dict):
- continue
- group = groups[_review_group_for_item(item)]
- group["expected"] += 1
- if item.get("state") == "pass":
- group["available"] += 1
- elif item.get("blocked") is True:
- group["blocked_details"].append(_review_detail(item))
- else:
- group["attention_details"].append(_review_detail(item))
-
- review_groups = []
- risks = []
- for group_id, _label in _REVIEW_GROUPS:
- group = groups[group_id]
- blocked_details = group["blocked_details"]
- attention_details = group["attention_details"]
- available = group["available"]
- expected = group["expected"]
- if blocked_details:
- status = "blocked"
- summary = f"{len(blocked_details)} blocking checklist requirements"
- risks.append(
- {
- "severity": "blocked",
- "title": f"{group['label']} signoff requirements block export",
- "summary": summary,
- "details": blocked_details,
- }
- )
- if attention_details:
- risks.append(
- {
- "severity": "warning",
- "title": f"{group['label']} signoff attention",
- "summary": (
- f"{len(attention_details)} attention-only checklist requirements"
- ),
- "details": attention_details,
- }
- )
- elif attention_details:
- status = "attention"
- summary = f"{len(attention_details)} attention-only checklist requirements"
- risks.append(
- {
- "severity": "warning",
- "title": f"{group['label']} signoff attention",
- "summary": summary,
- "details": attention_details,
- }
- )
- else:
- status = "ready"
- summary = (
- f"{available} of {expected} requirements ready" if expected else "No requirements"
- )
- review_groups.append(
- {
- "id": group_id,
- "label": group["label"],
- "status": status,
- "available": available,
- "expected": expected,
- "summary": summary,
- }
- )
-
- risks.sort(key=lambda risk: risk["severity"] != "blocked")
- status = checklist_data.get("status")
- return {
- "status": status if status in {"ready", "attention", "blocked"} else "blocked",
- "groups": review_groups,
- "risks": risks,
- }
-
-
-def _unavailable_review() -> dict:
- detail = {
- "kind": "freshness",
- "label": "Signoff checklist",
- "location": "home/checklist.json",
- "reason": "The current signoff checklist could not be generated.",
- "owner": "checklist",
- "policy": "block",
- "state": "unavailable",
- "evidence": [],
- }
- return {
- "status": "blocked",
- "groups": [
- {
- "id": group_id,
- "label": label,
- "status": "blocked" if group_id == "reports" else "ready",
- "available": 0,
- "expected": 0,
- "summary": "Checklist unavailable" if group_id == "reports" else "No requirements",
- }
- for group_id, label in _REVIEW_GROUPS
- ],
- "risks": [
- {
- "severity": "blocked",
- "title": "Signoff checklist unavailable",
- "summary": "Re-run signoff inspection after current-output analysis completes.",
- "details": [detail],
- }
- ],
- }
-
-
-def _review_detail(item: dict) -> dict:
- source = item.get("source", {})
- source = source if isinstance(source, dict) else {}
- evidence = item.get("evidence", [])
- return {
- "kind": item.get("category", "checklist"),
- "label": item.get("title", "Checklist item"),
- "location": source.get("path", "home/checklist.json"),
- "reason": item.get("summary", ""),
- "owner": item.get("owner", "checklist"),
- "policy": item.get("policy", "warn"),
- "state": item.get("state", "unavailable"),
- "evidence": evidence if isinstance(evidence, list) else [],
- }
-
-
-def _review_group_for_item(item: dict) -> str:
- step = str(item.get("step", ""))
- category = str(item.get("category", ""))
- source = item.get("source", {})
- path = source.get("path", "") if isinstance(source, dict) else ""
- if category == "configuration" or path.startswith("config/"):
- return "config"
- if category == "provenance" or path.startswith(("origin/", "initial/")):
- return "initial"
- if step == "Harden" or path.startswith(("Harden_ecc/", "harden/")):
- return "harden"
- if step == "sta" or path.startswith(("sta_ecc/", "final/timing/sta/")):
- return "sta"
- if step == "RCX" or path.startswith(("RCX_ecc/", "final/timing/spef/")):
- return "spef"
- if step in {"route", "drc", "lvs", "filler"} or path.startswith(
- ("route_ecc/", "drc_ecc/", "lvs_ecc/", "filler_ecc/", "final/design/")
- ):
- return "final_design"
- return "reports"
-
-
-def export_signoff_package_archive(
- workspace,
- output_path: str,
- additional_files: list[dict[str, str]] | None = None,
- *,
- include_debug: bool = False,
-) -> str:
- raw_destination = Path(output_path).expanduser()
- destination = raw_destination.parent.resolve() / raw_destination.name
-
- with tempfile.TemporaryDirectory(prefix="ecc-signoff-") as temporary_root:
- result = EngineFlow(workspace).collect_signoff_package(
- SignoffPackageOptions(
- output_dir=temporary_root,
- archive=False,
- include_debug=include_debug,
- refresh_analysis=True,
- )
- )
- if not result.ok:
- missing = ", ".join(result.missing_required) or "unknown required resources"
- raise RuntimeApiError(
- "command_failed",
- f"signoff package is incomplete: {missing}",
- )
- if not result.package_dir:
- raise RuntimeApiError(
- "command_failed",
- "signoff package directory was not created",
- )
-
- package_dir = Path(result.package_dir)
-
- if additional_files:
- package_root = package_dir.resolve()
- for file_info in additional_files:
- archive_path = file_info.get("archivePath") if isinstance(file_info, dict) else None
- content = file_info.get("content") if isinstance(file_info, dict) else None
- if not isinstance(archive_path, str) or not archive_path:
- raise RuntimeApiError(
- "invalid_request",
- "additionalFiles entries require a non-empty archivePath",
- )
- if not isinstance(content, str):
- raise RuntimeApiError(
- "invalid_request",
- "additionalFiles entries require string content",
- )
- relative_path = Path(archive_path)
- if (
- not relative_path.parts
- or relative_path.is_absolute()
- or ".." in relative_path.parts
- ):
- raise RuntimeApiError(
- "invalid_request",
- f"additional file path escapes signoff package: {archive_path}",
- )
- p = package_dir / relative_path
- try:
- p.resolve().relative_to(package_root)
- except ValueError as exc:
- raise RuntimeApiError(
- "invalid_request",
- f"additional file path escapes signoff package: {archive_path}",
- ) from exc
- p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(content, encoding="utf-8")
-
- archive = package_dir.with_suffix(".tar.gz")
- import tarfile
-
- with tarfile.open(archive, "w:gz") as tar:
- tar.add(package_dir, arcname=package_dir.name)
-
- destination.parent.mkdir(parents=True, exist_ok=True)
- descriptor, staged_name = tempfile.mkstemp(
- dir=destination.parent,
- prefix=f".{destination.name}.",
- )
- os.close(descriptor)
- staged_path = Path(staged_name)
- try:
- shutil.copy2(archive, staged_path)
- os.replace(staged_path, destination)
- finally:
- staged_path.unlink(missing_ok=True)
-
- return str(destination)
diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py
deleted file mode 100644
index de0f9ced..00000000
--- a/chipcompiler/runtime/stdio_server.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import os
-import queue
-import sys
-import threading
-from typing import BinaryIO
-
-from chipcompiler.runtime.server import RuntimeServer
-from chipcompiler.runtime.transport import (
- ContentLengthDecoder,
- TransportError,
- encode_content_length_frame,
-)
-
-_STOP = object()
-
-
-class _ProtocolWriter:
- """Serialize protocol output through an fd immune to tool stdio redirects."""
-
- def __init__(self, output_stream: BinaryIO):
- self._output_stream = output_stream
- self._output_fd: int | None = None
- try:
- self._output_fd = os.dup(output_stream.fileno())
- except (AttributeError, OSError):
- # BytesIO is used by unit tests and has no file descriptor.
- self._output_fd = None
- self._messages: queue.Queue[bytes | object] = queue.Queue()
- self._thread = threading.Thread(target=self._write_loop, name="ecc-rpc-writer", daemon=True)
- self._thread.start()
-
- def send_response(self, response: str) -> None:
- self._messages.put(encode_content_length_frame(response))
-
- def send_notification(self, method: str, params: dict) -> None:
- import json
-
- payload = json.dumps(
- {"jsonrpc": "2.0", "method": method, "params": params},
- separators=(",", ":"),
- )
- self._messages.put(encode_content_length_frame(payload))
-
- def close(self) -> None:
- self._messages.put(_STOP)
- self._thread.join()
- if self._output_fd is not None:
- os.close(self._output_fd)
- self._output_fd = None
-
- def _write_loop(self) -> None:
- while True:
- message = self._messages.get()
- if message is _STOP:
- return
- assert isinstance(message, bytes)
- if self._output_fd is None:
- self._output_stream.write(message)
- self._output_stream.flush()
- continue
- _write_all(self._output_fd, message)
-
-
-def _write_all(fd: int, data: bytes) -> None:
- view = memoryview(data)
- while view:
- written = os.write(fd, view)
- view = view[written:]
-
-
-def run_stdio_server(
- input_stream: BinaryIO,
- output_stream: BinaryIO,
- *,
- server: RuntimeServer | None = None,
- persistent_db_enabled: bool = False,
-) -> int:
- runtime_server = server or RuntimeServer(persistent_db_enabled=persistent_db_enabled)
- decoder = ContentLengthDecoder()
- writer = _ProtocolWriter(output_stream)
- runtime_server.set_notification_sink(writer.send_notification)
-
- try:
- while not runtime_server.should_exit:
- chunk = _read_chunk(input_stream)
- if not chunk:
- break
- try:
- messages = decoder.feed(chunk)
- except TransportError as exc:
- print(f"transport error: {exc}", file=sys.stderr)
- return 1
-
- for message in messages:
- response = runtime_server.dispatch(message)
- if runtime_server.should_exit and not response:
- break
- if response:
- writer.send_response(response)
- if runtime_server.should_exit:
- break
- return 0
- finally:
- runtime_server.set_notification_sink(None)
- writer.close()
-
-
-def _read_chunk(input_stream: BinaryIO) -> bytes:
- read1 = getattr(input_stream, "read1", None)
- if read1 is not None:
- return read1(8192)
- return input_stream.read(8192)
-
-
-def main(*, persistent_db_enabled: bool = False) -> int:
- return run_stdio_server(
- sys.stdin.buffer,
- sys.stdout.buffer,
- persistent_db_enabled=persistent_db_enabled,
- )
diff --git a/chipcompiler/runtime/transport.py b/chipcompiler/runtime/transport.py
deleted file mode 100644
index 666a9948..00000000
--- a/chipcompiler/runtime/transport.py
+++ /dev/null
@@ -1,61 +0,0 @@
-HEADER_SEPARATOR = b"\r\n\r\n"
-DEFAULT_MAX_PAYLOAD_SIZE = 16 * 1024 * 1024
-
-
-class TransportError(ValueError):
- """Raised when stdio framing is malformed before JSON-RPC dispatch."""
-
-
-def encode_content_length_frame(payload: bytes | str) -> bytes:
- payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload
- return b"Content-Length: %d\r\n\r\n" % len(payload_bytes) + payload_bytes
-
-
-class ContentLengthDecoder:
- def __init__(self, *, max_payload_size: int = DEFAULT_MAX_PAYLOAD_SIZE):
- self._buffer = bytearray()
- self._max_payload_size = max_payload_size
-
- def feed(self, data: bytes) -> list[bytes]:
- self._buffer.extend(data)
- messages: list[bytes] = []
-
- while True:
- header_end = self._buffer.find(HEADER_SEPARATOR)
- if header_end < 0:
- return messages
-
- header = bytes(self._buffer[:header_end]).decode("ascii", errors="replace")
- content_length = self._parse_content_length(header)
- payload_start = header_end + len(HEADER_SEPARATOR)
- payload_end = payload_start + content_length
- if len(self._buffer) < payload_end:
- return messages
-
- messages.append(bytes(self._buffer[payload_start:payload_end]))
- del self._buffer[:payload_end]
-
- def _parse_content_length(self, header: str) -> int:
- length_values: list[str] = []
- for line in header.split("\r\n"):
- if not line:
- continue
- name, separator, value = line.partition(":")
- if not separator:
- raise TransportError("malformed header line before Content-Length")
- if name.lower() == "content-length":
- length_values.append(value.strip())
-
- if len(length_values) != 1:
- raise TransportError("exactly one Content-Length header is required")
-
- try:
- content_length = int(length_values[0])
- except ValueError as exc:
- raise TransportError("Content-Length must be an integer") from exc
-
- if content_length < 0:
- raise TransportError("Content-Length must be non-negative")
- if content_length > self._max_payload_size:
- raise TransportError("Content-Length exceeds maximum payload size")
- return content_length
diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py
deleted file mode 100644
index ca72b912..00000000
--- a/chipcompiler/runtime/workspace_api.py
+++ /dev/null
@@ -1,2256 +0,0 @@
-import hashlib
-import inspect
-import json
-import os
-import shutil
-import tempfile
-import threading
-from collections.abc import Callable
-from copy import copy, deepcopy
-from dataclasses import replace
-from pathlib import Path
-from typing import Any, TypeVar
-
-from chipcompiler.runtime.operations import (
- RuntimeOperationConflict,
- RuntimeOperationManager,
-)
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- FloorplanEditInspectRequest,
- FloorplanEditRunAutoRequest,
- FloorplanEditValidateRequest,
- FlowRunRequest,
- FlowRunStepRequest,
- LayoutEditApplyRequest,
- LayoutEditBeginRequest,
- LayoutEditDiscardRequest,
- LayoutEditSaveRequest,
- OperationAckStepRenderedRequest,
- OperationIdRequest,
- OperationStartFlowRequest,
- OperationStartStepRequest,
- WorkspaceCreateRequest,
- WorkspaceExportSignoffRequest,
- WorkspaceIdRequest,
- WorkspaceInfoRequest,
- WorkspaceInspectSignoffRequest,
- WorkspaceOpenRequest,
- WorkspaceRecoverInterruptedRequest,
- WorkspaceSyncConfigRequest,
-)
-from chipcompiler.runtime.sessions import (
- LayoutEditSession,
- WorkspaceSession,
- WorkspaceSessionNotFound,
- WorkspaceSessionRegistry,
-)
-from chipcompiler.runtime.workspace_config_io import (
- canonical_request_parameters,
- read_workspace_state,
- workspace_state_bytes,
-)
-from chipcompiler.utility.path import path_is_within, stringify_paths
-
-_T = TypeVar("_T")
-
-
-class RuntimeApiError(RuntimeError):
- def __init__(self, code: str, message: str, data: dict | None = None):
- super().__init__(message)
- self.code = code
- self.message = message
- self.data = data or {}
-
-
-class WorkspaceRuntimeApi:
- def __init__(
- self,
- sessions: WorkspaceSessionRegistry | None = None,
- *,
- persistent_db_enabled: bool = False,
- event_publisher: Callable[[dict[str, Any]], None] | None = None,
- ):
- self.persistent_db_enabled = persistent_db_enabled
- self.sessions = sessions or WorkspaceSessionRegistry(db_releaser=_close_db_handle)
- self._next_layout_edit_id = 1
- self._layout_edit_sessions: dict[str, LayoutEditSession] = {}
- # ecc_tools_bin currently owns one process-global GeometryEditSession.
- # Keep its native DB and derived GeometryStore exclusive until that
- # session is reset, so a second workspace cannot replace the store
- # underneath an active editor.
- self._layout_edit_lock = threading.RLock()
- self.operations = RuntimeOperationManager(event_publisher)
-
- def set_event_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None:
- self.operations.set_publisher(publisher)
-
- def create_workspace(self, request: WorkspaceCreateRequest) -> dict:
- if not request.directory:
- raise RuntimeApiError("invalid_request", "missing required field: directory")
-
- temp_filelist_dir = None
- input_filelist = request.filelist
- if not input_filelist:
- rtl_paths = _normalize_rtl_list(request.rtl_list or [])
- if rtl_paths:
- temp_filelist_dir = tempfile.TemporaryDirectory(prefix="ecc-workspace-filelist-")
- input_filelist = _write_filelist(temp_filelist_dir.name, rtl_paths)
-
- import chipcompiler.data as data_api
-
- pdk_json, pdk_json_temp_path = _materialize_inline_pdk_json(request.pdk_json)
- try:
- workspace = data_api.create_workspace(
- directory=request.directory,
- pdk=request.pdk,
- parameters=canonical_request_parameters(request.parameters),
- origin_def=request.origin_def,
- origin_verilog=request.origin_verilog,
- input_filelist=input_filelist,
- pdk_root=request.pdk_root,
- pdk_json=pdk_json,
- sdc=request.sdc,
- flow_config=request.flow_config,
- )
- finally:
- if pdk_json_temp_path is not None:
- pdk_json_temp_path.unlink(missing_ok=True)
- if temp_filelist_dir is not None:
- temp_filelist_dir.cleanup()
- if workspace is None:
- raise RuntimeApiError(
- "command_failed",
- f"create workspace failed : {os.path.abspath(request.directory)}",
- )
-
- build_flow_for_workspace(workspace)
- session = self.sessions.create_session(workspace.directory, workspace=workspace)
- return _workspace_session_result(session)
-
- def open_workspace(self, request: WorkspaceOpenRequest) -> dict:
- workspace = self._load_workspace(request.directory)
- build_flow_for_workspace(workspace, create_step_workspaces=False)
- session = self.sessions.open_session(workspace.directory, workspace=workspace)
- return _workspace_session_result(session)
-
- def recover_interrupted(self, request: WorkspaceRecoverInterruptedRequest) -> dict:
- from chipcompiler.runtime.recovery import recover_interrupted_operation
-
- return self._with_session_mutation_lock(
- request.workspace_id,
- lambda session: recover_interrupted_operation(
- session.workspace,
- self.operations,
- request.operation_id,
- ),
- )
-
- def workspace_home(self, request: WorkspaceIdRequest) -> dict:
- session = self._get_session(request.workspace_id)
- path = Path(session.workspace.home.path).resolve()
- if not path.exists():
- raise RuntimeApiError("command_failed", f"get home failed : {path}")
- return {"path": str(path)}
-
- def workspace_info(self, request: WorkspaceInfoRequest) -> dict:
- session = self._get_session(request.workspace_id)
- workspace_step = _workspace_step_from_flow(session.workspace, request.step)
- if workspace_step is None:
- raise RuntimeApiError("command_failed", f"step not found: {request.step}")
-
- import chipcompiler.tools as tools_api
-
- info = tools_api.get_step_info(
- workspace=session.workspace,
- step=workspace_step,
- id=request.info_id,
- )
- return {
- "step": request.step,
- "id": request.info_id,
- "info": stringify_paths(info or {}),
- }
-
- def refresh_config(self, request: WorkspaceIdRequest) -> dict:
- def refresh(session: WorkspaceSession) -> dict:
- self._release_session_db(session)
- self._refresh_workspace_config(session.workspace)
- return {"directory": str(session.directory), "refreshed": True}
-
- return self._with_session_mutation_lock(request.workspace_id, refresh)
-
- def sync_config(self, request: WorkspaceSyncConfigRequest) -> dict:
- def sync(session: WorkspaceSession) -> dict:
- config_path = Path(request.config_path).resolve()
- config_dir = session.directory / "config"
- if not path_is_within(config_path, config_dir):
- raise RuntimeApiError(
- "invalid_request",
- f"config path outside workspace config directory : {config_path}",
- )
-
- import chipcompiler.data as data_api
-
- parameters_changed = data_api.sync_workspace_config_to_parameters(
- session.workspace,
- config_path,
- )
- refreshed = False
- if parameters_changed:
- self._release_session_db(session)
- self._refresh_workspace_config(session.workspace)
- refreshed = True
- return {
- "directory": str(session.directory),
- "configPath": str(config_path),
- "parametersChanged": bool(parameters_changed),
- "refreshed": refreshed,
- }
-
- return self._with_session_mutation_lock(request.workspace_id, sync)
-
- def reset_flow(self, request: WorkspaceIdRequest) -> dict:
- def reset(session: WorkspaceSession) -> dict:
- self._release_session_db(session)
- engine_flow = build_flow_for_workspace(session.workspace)
- self._prepare_workspace_for_rerun(session.workspace, engine_flow)
- return {"directory": str(session.directory)}
-
- return self._with_session_mutation_lock(request.workspace_id, reset)
-
- def export_signoff(self, request: WorkspaceExportSignoffRequest) -> dict:
- def export(session: WorkspaceSession) -> dict:
- from chipcompiler.runtime.signoff_export import (
- export_signoff_package_archive,
- )
-
- output_path = export_signoff_package_archive(
- session.workspace,
- request.output_path,
- request.additional_files,
- )
- return {"outputPath": output_path}
-
- return self._with_session_mutation_lock(request.workspace_id, export)
-
- def inspect_signoff(self, request: WorkspaceInspectSignoffRequest) -> dict:
- def inspect(session: WorkspaceSession) -> dict:
- from chipcompiler.runtime.signoff_export import inspect_signoff_package
-
- return inspect_signoff_package(session.workspace)
-
- return self._with_session_mutation_lock(request.workspace_id, inspect)
-
- def close_workspace(self, request: WorkspaceIdRequest) -> dict:
- def close(session: WorkspaceSession) -> dict:
- self._discard_layout_edit_session(session)
- self.sessions.close_session(session.workspace_id)
- return {"ok": True}
-
- return self._with_session_mutation_lock(request.workspace_id, close)
-
- def flow_run(self, request: FlowRunRequest) -> dict:
- return self._flow_run(request)
-
- def _flow_run(
- self,
- request: FlowRunRequest,
- *,
- observer=None,
- preserve_user_inputs: bool = False,
- ) -> dict:
- def run(session: WorkspaceSession) -> dict:
- should_capture = self._should_capture_session_db(session)
- previous_db = session.db_handle if should_capture else None
- if request.rerun and should_capture:
- self._release_session_db(session)
- previous_db = None
-
- engine_flow = self._build_flow_for_session(
- session,
- attach_session_db=should_capture and not request.rerun,
- )
- if request.rerun:
- affected_steps = list(getattr(engine_flow, "workspace_steps", []))
- self._prepare_workspace_for_rerun(
- session.workspace,
- engine_flow,
- preserve_user_inputs=preserve_user_inputs,
- )
- self._notify_rerun_prepared(
- observer,
- affected_steps,
- scope="flow",
- )
- try:
- ok = _run_engine_flow_steps(engine_flow, rerun=request.rerun, observer=observer)
- finally:
- if should_capture:
- self._capture_flow_db(
- session,
- engine_flow,
- previous_handle=previous_db,
- )
- else:
- self._close_transient_flow_db(engine_flow)
- if not ok:
- raise RuntimeApiError(
- "command_failed",
- f"run flow failed : {session.directory}",
- {"rerun": request.rerun},
- )
- return {"rerun": request.rerun}
-
- return self._with_session_mutation_lock(request.workspace_id, run)
-
- def flow_run_step(self, request: FlowRunStepRequest) -> dict:
- return self._flow_run_step(request)
-
- def _flow_run_step(
- self,
- request: FlowRunStepRequest,
- *,
- observer=None,
- reset_dependents: bool = False,
- ) -> dict:
- def run_step(session: WorkspaceSession) -> dict:
- should_capture = self._should_capture_session_db(session)
- previous_db = session.db_handle if should_capture else None
- if request.rerun and should_capture:
- self._release_session_db(session)
- previous_db = None
-
- engine_flow = self._build_flow_for_session(
- session,
- attach_session_db=should_capture and not request.rerun,
- )
- if request.rerun:
- if session.layout_edit_session is not None:
- raise RuntimeApiError(
- "layout_edit_active",
- "close the rendered layout before rerunning this step",
- )
- self._refresh_workspace_config(session.workspace)
-
- workspace_step = engine_flow.get_workspace_step(request.step)
- if workspace_step is None:
- raise RuntimeApiError("command_failed", f"step not found: {request.step}")
- if request.rerun:
- affected_steps = self._rerun_affected_steps(
- engine_flow,
- workspace_step,
- reset_dependents=reset_dependents,
- )
- self._prepare_steps_for_rerun(
- session.workspace,
- engine_flow,
- affected_steps,
- )
- self._notify_rerun_prepared(
- observer,
- affected_steps,
- scope="step",
- target_step=workspace_step.name,
- )
-
- try:
- step_already_succeeded = not request.rerun and engine_flow.check_state(
- name=workspace_step.name,
- tool=workspace_step.tool,
- state=_success_state(),
- )
- if not step_already_succeeded:
- _init_db_engine_for_workspace_step(engine_flow, workspace_step)
- state = _run_engine_flow_step(
- engine_flow,
- workspace_step,
- rerun=request.rerun,
- observer=observer,
- )
- finally:
- if should_capture:
- self._capture_flow_db(
- session,
- engine_flow,
- previous_handle=previous_db,
- )
- else:
- self._close_transient_flow_db(engine_flow)
-
- state_value = _state_value(state)
- result = {"step": request.step, "state": state_value}
- if state_value != "Success":
- raise RuntimeApiError(
- "command_failed",
- f"run step {request.step} failed with state {state_value}",
- result,
- )
- return result
-
- return self._with_session_mutation_lock(request.workspace_id, run_step)
-
- def start_flow_operation(self, request: OperationStartFlowRequest) -> dict:
- self._require_gui_operation_origin(request.origin)
- self._get_session(request.workspace_id)
- try:
- return self.operations.start(
- workspace_id=request.workspace_id,
- kind="flow",
- origin=request.origin,
- rerun=request.rerun,
- step="",
- idempotency_key=request.idempotency_key,
- runner=lambda observer: self._flow_run(
- FlowRunRequest(workspace_id=request.workspace_id, rerun=request.rerun),
- observer=observer,
- preserve_user_inputs=request.rerun,
- ),
- )
- except RuntimeOperationConflict as exc:
- raise RuntimeApiError("command_failed", str(exc)) from exc
-
- def start_step_operation(self, request: OperationStartStepRequest) -> dict:
- self._require_gui_operation_origin(request.origin)
- self._get_session(request.workspace_id)
- try:
- return self.operations.start(
- workspace_id=request.workspace_id,
- kind="step",
- origin=request.origin,
- rerun=request.rerun,
- step=request.step,
- idempotency_key=request.idempotency_key,
- runner=lambda observer: self._flow_run_step(
- FlowRunStepRequest(
- workspace_id=request.workspace_id,
- step=request.step,
- rerun=request.rerun,
- ),
- observer=observer,
- reset_dependents=request.reset_dependents,
- ),
- )
- except RuntimeOperationConflict as exc:
- raise RuntimeApiError("command_failed", str(exc)) from exc
-
- def operation_status(self, request: OperationIdRequest) -> dict:
- try:
- return self.operations.operation_status(request.operation_id)
- except KeyError as exc:
- raise RuntimeApiError(
- "invalid_request",
- f"operation not found: {request.operation_id}",
- ) from exc
-
- def cancel_operation(self, request: OperationIdRequest) -> dict:
- try:
- return self.operations.request_cancel(request.operation_id)
- except KeyError as exc:
- raise RuntimeApiError(
- "invalid_request",
- f"operation not found: {request.operation_id}",
- ) from exc
-
- def acknowledge_step_rendered(self, request: OperationAckStepRenderedRequest) -> dict:
- try:
- return self.operations.acknowledge_step_rendered(
- request.operation_id,
- request.event_id,
- request.step_commit_id,
- request.workspace_revision,
- )
- except KeyError as exc:
- raise RuntimeApiError(
- "invalid_request",
- f"operation not found: {request.operation_id}",
- ) from exc
-
- def workspace_snapshot(self, request: WorkspaceIdRequest) -> dict:
- session = self._get_session(request.workspace_id)
- flow_data = getattr(getattr(session.workspace, "flow", None), "data", {})
- raw_steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else []
- steps = [
- {
- "name": str(step.get("name", "")),
- "tool": str(step.get("tool", "")),
- "state": str(step.get("state", "Unstart")),
- "runtime": str(step.get("runtime", "")),
- "peakMemory": step.get("peak memory (mb)", 0),
- }
- for step in raw_steps
- if isinstance(step, dict)
- ]
- from chipcompiler.data.parameter import (
- parameters_have_chip_identity,
- reload_parameter,
- )
-
- parameters = getattr(session.workspace, "parameters", None)
- parameters_data = getattr(parameters, "data", {}) or {}
- if not parameters_have_chip_identity(parameters_data):
- parameter_path = getattr(parameters, "path", None)
- if parameter_path:
- session.workspace.parameters = reload_parameter(parameter_path, parameters)
- parameters_data = session.workspace.parameters.data or {}
-
- home_data = deepcopy(getattr(session.workspace.home, "data", {}) or {})
- if not str(home_data.get("parameters", "")).strip():
- parameter_path = getattr(session.workspace.parameters, "path", None)
- if parameter_path is None:
- from chipcompiler.data.workspace_config import workspace_config_path
-
- parameter_path = workspace_config_path(session.directory)
- home_data["parameters"] = str(parameter_path)
-
- return {
- **self.operations.workspace_snapshot(request.workspace_id),
- "directory": str(session.directory),
- "flow": {"steps": steps},
- "home": stringify_paths(home_data),
- "parameters": stringify_paths(deepcopy(parameters_data)),
- }
-
- def db_ensure(self, request: DbEnsureRequest) -> dict:
- self._require_persistent_db()
-
- def ensure(session: WorkspaceSession) -> dict:
- db_handle = session.db_handle
- if _db_handle_is_initialized(db_handle):
- return _db_ensure_result(
- workspace_id=session.workspace_id,
- step=request.step,
- active=True,
- reused=True,
- )
-
- engine_flow = build_flow_for_workspace(session.workspace)
- if db_handle is not None:
- engine_flow.engine_db = db_handle
-
- if request.step:
- workspace_step = engine_flow.get_workspace_step(request.step)
- if workspace_step is None:
- raise RuntimeApiError(
- "command_failed",
- f"step not found: {request.step}",
- )
- initialized = _init_db_engine_for_workspace_step(engine_flow, workspace_step)
- else:
- initialized = engine_flow.init_db_engine()
-
- flow_db = getattr(engine_flow, "engine_db", None)
- active = bool(initialized and _db_handle_is_initialized(flow_db))
- session.db_handle = flow_db if active else None
- return _db_ensure_result(
- workspace_id=session.workspace_id,
- step=request.step,
- active=active,
- reused=False,
- )
-
- return self._with_session_mutation_lock(request.workspace_id, ensure)
-
- def db_release(self, request: DbReleaseRequest) -> dict:
- self._require_persistent_db()
-
- def release(session: WorkspaceSession) -> dict:
- released = self._release_session_db(session)
- return {"workspaceId": session.workspace_id, "released": released}
-
- return self._with_session_mutation_lock(request.workspace_id, release)
-
- def layout_edit_begin(self, request: LayoutEditBeginRequest) -> dict:
- self._require_persistent_db()
-
- def begin(session: WorkspaceSession) -> dict:
- with self._layout_edit_lock:
- active_session = session.layout_edit_session
- if active_session is not None:
- if active_session.step_name != request.step:
- raise RuntimeApiError(
- "layout_edit_active",
- "layout edit session already active for step: "
- f"{active_session.step_name}",
- {"editSessionId": active_session.edit_session_id},
- )
- if (
- request.expected_source_fingerprint
- and request.expected_source_fingerprint != active_session.source_fingerprint
- ):
- raise RuntimeApiError(
- "source_changed",
- "layout edit source fingerprint does not match",
- {
- "expectedSourceFingerprint": request.expected_source_fingerprint,
- "actualSourceFingerprint": active_session.source_fingerprint,
- },
- )
- return _layout_edit_begin_result(active_session, reused=True)
-
- active_session = next(iter(self._layout_edit_sessions.values()), None)
- if active_session is not None:
- raise RuntimeApiError(
- "layout_edit_active",
- "layout edit session already active for another workspace",
- {
- "editSessionId": active_session.edit_session_id,
- "workspaceId": active_session.workspace_id,
- },
- )
-
- engine_flow = build_flow_for_workspace(session.workspace)
- workspace_step = engine_flow.get_workspace_step(request.step)
- if workspace_step is None:
- raise RuntimeApiError("command_failed", f"step not found: {request.step}")
-
- source_kind, source_paths = _layout_edit_source(workspace_step)
- source_fingerprint = _artifact_fingerprint(source_paths)
- if (
- request.expected_source_fingerprint
- and request.expected_source_fingerprint != source_fingerprint
- ):
- raise RuntimeApiError(
- "source_changed",
- "layout edit source fingerprint does not match",
- {
- "expectedSourceFingerprint": request.expected_source_fingerprint,
- "actualSourceFingerprint": source_fingerprint,
- },
- )
-
- edit_step = _layout_edit_workspace_step(
- workspace_step,
- source_kind=source_kind,
- source_paths=source_paths,
- )
- initialized = _init_db_engine_for_workspace_step(engine_flow, edit_step)
- db_handle = getattr(engine_flow, "engine_db", None)
- if not initialized or not _db_handle_is_initialized(db_handle):
- _close_db_handle(db_handle)
- raise RuntimeApiError(
- "command_failed",
- f"failed to initialize layout edit DB for step: {request.step}",
- )
-
- module = _db_engine_module(db_handle)
- initialize_geometry = getattr(module, "initialize_geometry_session", None)
- if not callable(initialize_geometry) or not initialize_geometry():
- _close_db_handle(db_handle)
- raise RuntimeApiError(
- "command_failed",
- f"failed to initialize layout geometry for step: {request.step}",
- )
-
- edit_session_id = self._new_layout_edit_id()
- geometry_output_dir = (
- Path(tempfile.mkdtemp(prefix=f"ecc-{edit_session_id}-geometry-")) / "geometry-0"
- )
- try:
- _write_layout_edit_geometry_snapshot(module, geometry_output_dir)
- except Exception:
- _close_db_handle(db_handle)
- shutil.rmtree(geometry_output_dir.parent, ignore_errors=True)
- raise
-
- edit_session = LayoutEditSession(
- edit_session_id=edit_session_id,
- workspace_id=session.workspace_id,
- step_name=request.step,
- workspace_step=workspace_step,
- db_handle=db_handle,
- source_kind=source_kind,
- source_paths=source_paths,
- source_fingerprint=source_fingerprint,
- geometry_output_dir=geometry_output_dir,
- )
- session.layout_edit_session = edit_session
- self._layout_edit_sessions[edit_session.edit_session_id] = edit_session
- return _layout_edit_begin_result(edit_session, reused=False)
-
- return self._with_session_mutation_lock(request.workspace_id, begin)
-
- def layout_edit_apply(self, request: LayoutEditApplyRequest) -> dict:
- def apply(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- return _apply_layout_edit_operation(
- edit_session,
- command_id=request.command_id,
- base_revision=request.base_revision,
- operation=request.operation,
- )
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, apply)
-
- def floorplan_edit_inspect(self, request: FloorplanEditInspectRequest) -> dict:
- def inspect(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- module = _db_engine_module(edit_session.db_handle)
- module_state = _floorplan_editor_inspect(module)
- return {
- "editSessionId": edit_session.edit_session_id,
- "revision": edit_session.revision,
- "geometryRevision": edit_session.geometry_revision,
- "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"),
- "dirty": edit_session.dirty,
- "floorplanPlan": deepcopy(edit_session.floorplan_plan),
- "pdnPlan": deepcopy(edit_session.pdn_plan),
- "diagnostics": deepcopy(edit_session.validation_diagnostics),
- "state": module_state,
- }
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, inspect)
-
- def floorplan_edit_run_auto(self, request: FloorplanEditRunAutoRequest) -> dict:
- def run_auto(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- if not isinstance(request.request, dict):
- raise RuntimeApiError("invalid_request", "request must be an object")
- return _apply_layout_edit_operation(
- edit_session,
- command_id=request.command_id,
- base_revision=request.base_revision,
- operation={"kind": "run_auto", "request": request.request},
- )
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, run_auto)
-
- def floorplan_edit_validate(self, request: FloorplanEditValidateRequest) -> dict:
- def validate(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- scope = request.scope.strip() if isinstance(request.scope, str) else ""
- if not scope:
- raise RuntimeApiError("invalid_request", "scope must be a non-empty string")
- module = _db_engine_module(edit_session.db_handle)
- result = _floorplan_editor_validate(module, scope)
- edit_session.validation_diagnostics = _floorplan_diagnostics(result)
- return {
- "editSessionId": edit_session.edit_session_id,
- "revision": edit_session.revision,
- "scope": scope,
- "valid": _floorplan_validation_ok(result),
- "diagnostics": deepcopy(edit_session.validation_diagnostics),
- }
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, validate)
-
- def layout_edit_save(self, request: LayoutEditSaveRequest) -> dict:
- def save(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- _validate_layout_edit_revision(request.expected_revision, "expected_revision")
- if request.expected_revision != edit_session.revision:
- raise RuntimeApiError(
- "version_conflict",
- "layout edit revision does not match",
- {
- "expectedRevision": request.expected_revision,
- "actualRevision": edit_session.revision,
- },
- )
- if not edit_session.dirty:
- return _layout_edit_save_result(edit_session, saved=False)
-
- current_fingerprint = _artifact_fingerprint(edit_session.source_paths)
- if current_fingerprint != edit_session.source_fingerprint:
- raise RuntimeApiError(
- "source_changed",
- "layout edit source changed after the session began",
- {
- "expectedSourceFingerprint": edit_session.source_fingerprint,
- "actualSourceFingerprint": current_fingerprint,
- },
- )
-
- module = _db_engine_module(edit_session.db_handle)
- if edit_session.used_floorplan_editor:
- validation = _floorplan_editor_validate(module, "all")
- edit_session.validation_diagnostics = _floorplan_diagnostics(validation)
- if not _floorplan_validation_ok(validation):
- raise RuntimeApiError(
- "floorplan_validation_failed",
- "floorplan edit validation failed",
- {"diagnostics": deepcopy(edit_session.validation_diagnostics)},
- )
- _merge_floorplan_export_intent(
- edit_session, _floorplan_editor_export_intent(module)
- )
-
- artifacts = _publish_layout_edit_artifacts(edit_session, session.workspace)
- output_db = _path_or_none(
- _workspace_step_output_value(edit_session.workspace_step, "db")
- )
- if output_db is None:
- raise RuntimeApiError("command_failed", "layout edit output DB is missing")
- edit_session.source_kind = "db"
- edit_session.source_paths = (output_db,)
- edit_session.source_fingerprint = _artifact_fingerprint(edit_session.source_paths)
- edit_session.dirty = False
- return _layout_edit_save_result(edit_session, saved=True, artifacts=artifacts)
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, save)
-
- def layout_edit_discard(self, request: LayoutEditDiscardRequest) -> dict:
- def discard(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict:
- dirty = edit_session.dirty
- self._discard_layout_edit_session(session)
- return {
- "editSessionId": request.edit_session_id,
- "discarded": True,
- "dirty": dirty,
- }
-
- return self._with_layout_edit_session_mutation_lock(request.edit_session_id, discard)
-
- def _load_workspace(self, directory: str):
- if not directory:
- raise RuntimeApiError("invalid_request", "missing required field: directory")
- if not _looks_like_old_workspace(directory):
- raise RuntimeApiError("invalid_request", f"invalid workspace directory: {directory}")
-
- import chipcompiler.data as data_api
-
- workspace = data_api.load_workspace(directory=directory)
- if workspace is None:
- raise RuntimeApiError("command_failed", f"load workspace failed : {directory}")
- return workspace
-
- def _get_session(self, workspace_id: str) -> WorkspaceSession:
- try:
- return self.sessions.get_session(workspace_id)
- except WorkspaceSessionNotFound as exc:
- raise RuntimeApiError(
- "workspace_session_not_found",
- f"workspace session not found: {workspace_id}",
- ) from exc
-
- def _require_persistent_db(self) -> None:
- if not self.persistent_db_enabled:
- raise RuntimeApiError("command_failed", "persistent_db_disabled")
-
- @staticmethod
- def _require_gui_operation_origin(origin: str) -> None:
- if origin != "gui":
- raise RuntimeApiError("invalid_request", "operation origin must be gui")
-
- def _new_layout_edit_id(self) -> str:
- edit_session_id = f"layout-edit-{self._next_layout_edit_id}"
- self._next_layout_edit_id += 1
- return edit_session_id
-
- def _with_layout_edit_session_mutation_lock(
- self,
- edit_session_id: str,
- operation: Callable[[WorkspaceSession, LayoutEditSession], _T],
- ) -> _T:
- edit_session = self._layout_edit_sessions.get(edit_session_id)
- if edit_session is None:
- raise RuntimeApiError(
- "layout_edit_session_not_found",
- f"layout edit session not found: {edit_session_id}",
- )
-
- def run(session: WorkspaceSession) -> _T:
- if session.layout_edit_session is not edit_session:
- self._layout_edit_sessions.pop(edit_session_id, None)
- raise RuntimeApiError(
- "layout_edit_session_not_found",
- f"layout edit session not found: {edit_session_id}",
- )
- return operation(session, edit_session)
-
- return self._with_session_mutation_lock(edit_session.workspace_id, run)
-
- def _discard_layout_edit_session(self, session: WorkspaceSession) -> bool:
- with self._layout_edit_lock:
- edit_session = session.layout_edit_session
- if edit_session is None:
- return False
- self._layout_edit_sessions.pop(edit_session.edit_session_id, None)
- return self.sessions.release_layout_edit_session(session)
-
- def _release_session_db(self, session: WorkspaceSession) -> bool:
- return self.sessions.release_session_db(session)
-
- def _should_capture_session_db(self, session: WorkspaceSession) -> bool:
- return self.persistent_db_enabled and _db_handle_is_initialized(session.db_handle)
-
- def _build_flow_for_session(
- self,
- session: WorkspaceSession,
- *,
- attach_session_db: bool,
- ):
- engine_flow = build_flow_for_workspace(session.workspace)
- if attach_session_db:
- engine_flow.engine_db = session.db_handle
- return engine_flow
-
- def _capture_flow_db(
- self,
- session: WorkspaceSession,
- engine_flow,
- *,
- previous_handle,
- ) -> None:
- flow_db = getattr(engine_flow, "engine_db", None)
- if _db_handle_is_initialized(flow_db):
- session.db_handle = flow_db
- if previous_handle is not None and previous_handle is not flow_db:
- _close_db_handle(previous_handle)
- return
-
- session.db_handle = None
- if previous_handle is not None:
- _close_db_handle(previous_handle)
-
- def _close_transient_flow_db(self, engine_flow) -> None:
- _close_db_handle(getattr(engine_flow, "engine_db", None))
-
- def _with_session_mutation_lock(
- self,
- workspace_id: str,
- operation: Callable[[WorkspaceSession], _T],
- ) -> _T:
- session = self._get_session(workspace_id)
- with session.mutation_lock:
- return operation(session)
-
- def _refresh_workspace_config(self, workspace) -> None:
- import chipcompiler.data as data_api
-
- data_api.refresh_workspace_config(workspace)
-
- def _prepare_workspace_for_rerun(
- self,
- workspace,
- engine_flow,
- *,
- preserve_user_inputs: bool = False,
- ) -> None:
- import chipcompiler.data as data_api
-
- data_api.prepare_workspace_for_rerun(
- workspace,
- engine_flow,
- preserve_user_inputs=preserve_user_inputs,
- )
-
- @staticmethod
- def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool):
- if not reset_dependents:
- return [workspace_step]
- workspace_steps = list(getattr(engine_flow, "workspace_steps", []))
- try:
- start_index = workspace_steps.index(workspace_step)
- except ValueError:
- return [workspace_step]
- return workspace_steps[start_index:]
-
- @staticmethod
- def _notify_rerun_prepared(
- observer,
- workspace_steps,
- *,
- scope: str,
- target_step: str = "",
- ) -> None:
- callback = getattr(observer, "on_rerun_prepared", None)
- if callback is None:
- return
- callback(
- affected_steps=[str(getattr(step, "name", "")) for step in workspace_steps],
- scope=scope,
- target_step=target_step,
- )
-
- @staticmethod
- def _prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None:
- WorkspaceRuntimeApi._prepare_steps_for_rerun(
- workspace,
- engine_flow,
- [workspace_step],
- )
-
- @staticmethod
- def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None:
- workspace_root = Path(workspace.directory).resolve()
- unique_steps = []
- known_step_keys = set()
- for workspace_step in workspace_steps:
- key = (
- str(getattr(workspace_step, "name", "")),
- str(getattr(workspace_step, "tool", "")),
- )
- if key in known_step_keys:
- continue
- known_step_keys.add(key)
- unique_steps.append(workspace_step)
-
- artifact_directories = []
- known_directories = set()
- for workspace_step in unique_steps:
- for directory in WorkspaceRuntimeApi._step_artifact_dirs(workspace_step):
- resolved = WorkspaceRuntimeApi._validate_step_artifact_dir(
- workspace_root,
- directory,
- workspace_step.name,
- )
- if resolved in known_directories:
- continue
- known_directories.add(resolved)
- artifact_directories.append((workspace_step.name, directory))
-
- for step_name, directory in artifact_directories:
- WorkspaceRuntimeApi._clear_step_artifact_dir(
- workspace_root,
- directory,
- step_name,
- )
-
- updated_record = False
- for workspace_step in unique_steps:
- record = engine_flow.get_step(workspace_step.name, workspace_step.tool)
- if record is None:
- continue
- record.update(
- {
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- "info": {},
- }
- )
- updated_record = True
- if updated_record:
- engine_flow.save()
-
- for workspace_step in unique_steps:
- WorkspaceRuntimeApi._reset_step_subflow(workspace_step)
- WorkspaceRuntimeApi._reset_step_checklist(workspace_step)
-
- @staticmethod
- def _reset_step_subflow(workspace_step) -> None:
- from chipcompiler.utility import json_read, json_write
-
- subflow = getattr(workspace_step, "subflow", None)
- path = getattr(subflow, "path", None)
- if not path:
- return
- subflow_path = Path(path)
- data = json_read(subflow_path)
- steps = data.get("steps", []) if isinstance(data, dict) else []
- if not isinstance(steps, list):
- return
- for step in steps:
- if not isinstance(step, dict):
- continue
- step.update(
- {
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- "info": {},
- }
- )
- json_write(subflow_path, {"path": str(subflow_path), "steps": steps})
- subflow.steps = steps
-
- @staticmethod
- def _reset_step_checklist(workspace_step) -> None:
- from chipcompiler.data import Checklist
-
- checklist = getattr(workspace_step, "checklist", None)
- path = getattr(checklist, "path", None)
- if not path:
- return
- checklist_path = Path(path)
- Checklist(checklist_path).replace([])
- checklist.checklist = []
-
- @staticmethod
- def _step_artifact_dirs(step) -> tuple[Path, ...]:
- directories: list[Path] = []
- for field in ("output", "data", "feature", "analysis", "report", "log"):
- value = getattr(step, field, {})
- directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None)
- if directory:
- directories.append(Path(directory))
- return tuple(dict.fromkeys(directories))
-
- @staticmethod
- def _clear_step_artifact_dir(
- workspace_root: Path,
- directory: Path,
- step_name: str,
- ) -> None:
- WorkspaceRuntimeApi._validate_step_artifact_dir(workspace_root, directory, step_name)
- if directory.exists():
- if not directory.is_dir():
- raise RuntimeApiError(
- "command_failed",
- f"step artifact is not a directory: {step_name}",
- )
- shutil.rmtree(directory)
- directory.mkdir(parents=True, exist_ok=True)
-
- @staticmethod
- def _validate_step_artifact_dir(
- workspace_root: Path,
- directory: Path,
- step_name: str,
- ) -> Path:
- resolved = directory.resolve()
- if (
- resolved == workspace_root
- or not path_is_within(resolved, workspace_root)
- or directory.is_symlink()
- ):
- raise RuntimeApiError(
- "command_failed",
- f"step artifact escapes workspace: {step_name}",
- )
- if directory.exists() and not directory.is_dir():
- raise RuntimeApiError(
- "command_failed",
- f"step artifact is not a directory: {step_name}",
- )
- return resolved
-
-
-def _layout_edit_begin_result(edit_session: LayoutEditSession, *, reused: bool) -> dict:
- return {
- "editSessionId": edit_session.edit_session_id,
- "workspaceId": edit_session.workspace_id,
- "step": edit_session.step_name,
- "source": edit_session.source_kind,
- "sourceFingerprint": edit_session.source_fingerprint,
- "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"),
- "revision": edit_session.revision,
- "geometryRevision": edit_session.geometry_revision,
- "dirty": edit_session.dirty,
- "reused": reused,
- }
-
-
-def _layout_edit_save_result(
- edit_session: LayoutEditSession,
- *,
- saved: bool,
- artifacts: dict[str, str] | None = None,
-) -> dict:
- if artifacts is None:
- artifacts = _layout_edit_published_artifacts(edit_session.workspace_step)
- return {
- "editSessionId": edit_session.edit_session_id,
- "revision": edit_session.revision,
- "geometryRevision": edit_session.geometry_revision,
- "dirty": edit_session.dirty,
- "saved": saved,
- "sourceFingerprint": edit_session.source_fingerprint,
- "artifacts": artifacts,
- }
-
-
-def _layout_edit_published_artifacts(workspace_step) -> dict:
- geometry_dir = _path_or_none(_workspace_step_output_value(workspace_step, "geometry"))
- geometry_manifest = _path_or_none(
- _workspace_step_output_value(workspace_step, "geometry_manifest")
- )
- if geometry_manifest is None and geometry_dir is not None:
- geometry_manifest = geometry_dir / "geometry.manifest"
- return {
- "defPath": _path_text(_workspace_step_output_value(workspace_step, "def")),
- "dbPath": _path_text(_workspace_step_output_value(workspace_step, "db")),
- "gdsPath": _path_text(_workspace_step_output_value(workspace_step, "gds")),
- "geometryManifestPath": str(geometry_manifest) if geometry_manifest else "",
- }
-
-
-def _layout_edit_source(workspace_step) -> tuple[str, tuple[Path, ...]]:
- output_db = _path_or_none(_workspace_step_output_value(workspace_step, "db"))
- if output_db is not None and output_db.is_dir():
- return "db", (output_db,)
-
- output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def"))
- if output_def is not None:
- return "def", (output_def,)
-
- raise RuntimeApiError(
- "command_failed",
- f"layout edit source missing for step: {getattr(workspace_step, 'name', '')}",
- )
-
-
-def _layout_edit_workspace_step(
- workspace_step,
- *,
- source_kind: str,
- source_paths: tuple[Path, ...],
-):
- edit_input = copy(getattr(workspace_step, "input", {}))
- output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def"))
- if isinstance(edit_input, dict):
- edit_input["db"] = source_paths[0] if source_kind == "db" else None
- edit_input["def"] = output_def
- edit_step = copy(workspace_step)
- edit_step.input = edit_input
- return edit_step
-
- edit_input.db = source_paths[0] if source_kind == "db" else None
- edit_input.def_ = output_def
- return replace(workspace_step, input=edit_input)
-
-
-_FLOORPLAN_EDITOR_OPERATION_KINDS = frozenset(
- {
- "set_floorplan_outline",
- "replace_tracks",
- "upsert_io_pin_port",
- "upsert_blockage",
- "delete_blockage",
- "upsert_instance_halo",
- "delete_instance_halo",
- "pdn.plan.patch",
- "pdn.manual_segment.upsert",
- "pdn.manual_segment.delete",
- "pdn.manual_via.upsert",
- "pdn.manual_via.delete",
- "run_auto",
- }
-)
-
-
-def _apply_layout_edit_operation(
- edit_session: LayoutEditSession,
- *,
- command_id: object,
- base_revision: object,
- operation: object,
-) -> dict:
- if not isinstance(command_id, str) or not command_id.strip():
- raise RuntimeApiError("invalid_request", "missing required field: command_id")
-
- previous_result = edit_session.command_results.get(command_id)
- if previous_result is not None:
- return previous_result
-
- _validate_layout_edit_revision(base_revision, "base_revision")
- if base_revision != edit_session.revision:
- raise RuntimeApiError(
- "version_conflict",
- "layout edit revision does not match",
- {
- "expectedRevision": base_revision,
- "actualRevision": edit_session.revision,
- },
- )
- if not isinstance(operation, dict):
- raise RuntimeApiError("invalid_request", "operation must be an object")
-
- kind = operation.get("kind")
- if kind == "place_instance":
- result = _apply_layout_edit_place_instance(edit_session, command_id, operation)
- else:
- result = _apply_floorplan_editor_operation(edit_session, command_id, operation)
- edit_session.command_results[command_id] = result
- return result
-
-
-def _apply_layout_edit_place_instance(
- edit_session: LayoutEditSession,
- command_id: str,
- operation: dict[str, Any],
-) -> dict:
- placement = _layout_edit_place_instance_operation(operation)
- module = _db_engine_module(edit_session.db_handle)
- place_instance = getattr(module, "place_instance", None)
- if not callable(place_instance):
- raise RuntimeApiError("command_failed", "place_instance is unavailable")
-
- accepted = place_instance(
- inst_name=placement["inst_name"],
- llx=placement["llx"],
- lly=placement["lly"],
- orient=placement["orient"],
- cellmaster=placement["cellmaster"],
- source=placement["source"],
- placement_status=placement["placement_status"],
- create_if_missing=placement["create_if_missing"],
- )
- if not accepted:
- raise RuntimeApiError(
- "placement_rejected",
- "place_instance rejected the requested placement",
- {"instanceName": placement["inst_name"]},
- )
-
- geometry_delta = _sync_layout_edit_instance_geometry(module, placement["inst_name"])
- geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module)
- edit_session.revision += 1
- edit_session.geometry_revision += 1
- edit_session.dirty = True
- if placement["create_if_missing"]:
- edit_session.requires_verilog = True
- return {
- "editSessionId": edit_session.edit_session_id,
- "commandId": command_id,
- "revision": edit_session.revision,
- "geometryRevision": edit_session.geometry_revision,
- "geometryManifestPath": str(geometry_manifest_path),
- "dirty": True,
- "operation": {
- "kind": "place_instance",
- "instanceName": placement["inst_name"],
- "origin": {"x": placement["llx"], "y": placement["lly"]},
- "orient": placement["orient"],
- },
- "geometryDelta": geometry_delta,
- }
-
-
-def _apply_floorplan_editor_operation(
- edit_session: LayoutEditSession,
- command_id: str,
- operation: dict[str, Any],
-) -> dict:
- kind = operation.get("kind")
- if not isinstance(kind, str) or kind not in _FLOORPLAN_EDITOR_OPERATION_KINDS:
- raise RuntimeApiError("invalid_request", "unsupported layout edit operation")
-
- module = _db_engine_module(edit_session.db_handle)
- editor_result = _floorplan_editor_apply(module, operation)
- if not _floorplan_editor_accepted(editor_result):
- diagnostics = _floorplan_diagnostics(editor_result)
- raise RuntimeApiError(
- "floorplan_rejected",
- "floorplan editor rejected the requested operation",
- {"operationKind": kind, "diagnostics": diagnostics},
- )
-
- model_patch = _floorplan_model_patch(editor_result)
- _merge_floorplan_model_patch(edit_session, model_patch)
- diagnostics = _floorplan_diagnostics(editor_result)
- edit_session.validation_diagnostics = diagnostics
- changed = bool(editor_result.get("changed", True))
- geometry_delta = _floorplan_geometry_delta(editor_result)
- geometry_manifest_path = edit_session.geometry_output_dir / "geometry.manifest"
- if changed:
- geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module)
- edit_session.revision += 1
- edit_session.geometry_revision += 1
- edit_session.dirty = True
- edit_session.used_floorplan_editor = True
- if _floorplan_bool(editor_result, "instancesChanged", "instances_changed") or _floorplan_bool(
- model_patch,
- "instancesChanged",
- "instances_changed",
- ):
- edit_session.requires_verilog = True
-
- return {
- "editSessionId": edit_session.edit_session_id,
- "commandId": command_id,
- "revision": edit_session.revision,
- "geometryRevision": edit_session.geometry_revision,
- "geometryManifestPath": str(geometry_manifest_path),
- "dirty": edit_session.dirty,
- "operation": {"kind": kind},
- "affectedRefs": _floorplan_affected_refs(editor_result),
- "geometryDelta": geometry_delta,
- "modelPatch": model_patch,
- "diagnostics": diagnostics,
- "changed": changed,
- }
-
-
-def _advance_layout_edit_geometry_snapshot(edit_session: LayoutEditSession, module) -> Path:
- next_geometry_output_dir = (
- edit_session.geometry_output_dir.parent / f"geometry-{edit_session.geometry_revision + 1}"
- )
- geometry_manifest_path = _write_layout_edit_geometry_snapshot(module, next_geometry_output_dir)
- previous_geometry_output_dir = edit_session.geometry_output_dir
- edit_session.geometry_output_dir = next_geometry_output_dir
- shutil.rmtree(previous_geometry_output_dir, ignore_errors=True)
- return geometry_manifest_path
-
-
-def _floorplan_editor_apply(module, operation: dict[str, Any]) -> dict[str, Any]:
- apply = getattr(module, "floorplan_editor_apply", None)
- if not callable(apply):
- apply = getattr(module, "floorplan_edit_apply", None)
- if not callable(apply):
- raise RuntimeApiError("command_failed", "floorplan editor is unavailable")
- payload = deepcopy(operation)
- result = _call_floorplan_editor_apply(apply, payload)
- if not isinstance(result, dict):
- raise RuntimeApiError("command_failed", "floorplan editor returned an invalid result")
- return result
-
-
-def _call_floorplan_editor_apply(apply, payload: dict[str, Any]):
- try:
- signature = inspect.signature(apply)
- except (TypeError, ValueError):
- return apply(request=payload)
-
- parameters = signature.parameters.values()
- accepts_keyword_request = "request" in signature.parameters or any(
- parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters
- )
- if accepts_keyword_request:
- return apply(request=payload)
- return apply(payload)
-
-
-def _floorplan_editor_inspect(module) -> dict[str, Any]:
- inspect = getattr(module, "floorplan_editor_inspect", None)
- if not callable(inspect):
- inspect = getattr(module, "floorplan_edit_inspect", None)
- if not callable(inspect):
- return {}
- result = inspect()
- return deepcopy(result) if isinstance(result, dict) else {}
-
-
-def _floorplan_editor_validate(module, scope: str) -> dict[str, Any]:
- validate = getattr(module, "floorplan_editor_validate", None)
- if not callable(validate):
- validate = getattr(module, "floorplan_validate", None)
- if not callable(validate):
- return {"ok": True, "diagnostics": []}
- try:
- result = validate(scope=scope)
- except TypeError:
- result = validate(scope)
- if not isinstance(result, dict):
- raise RuntimeApiError("command_failed", "floorplan validation returned an invalid result")
- return result
-
-
-def _floorplan_editor_export_intent(module) -> dict[str, Any]:
- export_intent = getattr(module, "floorplan_editor_export_intent", None)
- if not callable(export_intent):
- export_intent = getattr(module, "floorplan_export_intent", None)
- if not callable(export_intent):
- raise RuntimeApiError("command_failed", "floorplan editor export is unavailable")
- result = export_intent()
- if not isinstance(result, dict):
- raise RuntimeApiError(
- "command_failed", "floorplan editor export returned an invalid result"
- )
- if not _floorplan_editor_accepted(result):
- raise RuntimeApiError(
- "command_failed",
- "floorplan editor failed to export edit intent",
- {"diagnostics": _floorplan_diagnostics(result)},
- )
- return result
-
-
-def _floorplan_editor_accepted(result: dict[str, Any]) -> bool:
- return bool(result.get("accepted", result.get("ok", False)))
-
-
-def _floorplan_validation_ok(result: dict[str, Any]) -> bool:
- return bool(result.get("ok", result.get("accepted", False)))
-
-
-def _floorplan_diagnostics(result: dict[str, Any]) -> list[dict[str, Any]]:
- raw = result.get("diagnostics", [])
- if not isinstance(raw, list):
- return []
- diagnostics = []
- for item in raw:
- if isinstance(item, dict):
- diagnostics.append(deepcopy(item))
- elif isinstance(item, str):
- diagnostics.append({"message": item})
- return diagnostics
-
-
-def _floorplan_affected_refs(result: dict[str, Any]) -> list[Any]:
- refs = result.get("affectedRefs", result.get("affected_refs", []))
- return deepcopy(refs) if isinstance(refs, list) else []
-
-
-def _floorplan_geometry_delta(result: dict[str, Any]) -> dict[str, Any]:
- delta = result.get("geometryDelta", result.get("geometry_delta", {}))
- if not isinstance(delta, dict):
- delta = {}
- return {
- "ok": bool(delta.get("ok", True)),
- "snapshotRequired": bool(
- delta.get(
- "snapshotRequired",
- delta.get(
- "snapshot_required",
- result.get("snapshotRequired", result.get("snapshot_required", False)),
- ),
- )
- ),
- "updatedShapeCount": _geometry_delta_count(delta, "updatedShapeCount"),
- "insertedShapeCount": _geometry_delta_count(delta, "insertedShapeCount"),
- "deletedShapeCount": _geometry_delta_count(delta, "deletedShapeCount"),
- "missingShapeCount": _geometry_delta_count(delta, "missingShapeCount"),
- "events": deepcopy(delta.get("events", []))
- if isinstance(delta.get("events", []), list)
- else [],
- }
-
-
-def _floorplan_model_patch(result: dict[str, Any]) -> dict[str, Any]:
- patch = result.get("modelPatch", result.get("model_patch", {}))
- if patch is None:
- return {}
- if not isinstance(patch, dict):
- raise RuntimeApiError("command_failed", "floorplan editor returned an invalid model patch")
- return deepcopy(patch)
-
-
-def _floorplan_bool(data: dict[str, Any], *keys: str) -> bool:
- return any(bool(data.get(key, False)) for key in keys)
-
-
-def _merge_floorplan_model_patch(
- edit_session: LayoutEditSession, model_patch: dict[str, Any]
-) -> None:
- floorplan_plan = _floorplan_patch_mapping(model_patch, "floorplanPlan", "floorplan_plan")
- pdn_plan = _floorplan_patch_mapping(model_patch, "pdnPlan", "pdn_plan")
- config_patch = _floorplan_patch_mapping(model_patch, "configPatch", "config_patch")
- parameters_patch = _floorplan_patch_mapping(
- model_patch,
- "parametersPatch",
- "parameters_patch",
- )
- _deep_merge(edit_session.floorplan_plan, floorplan_plan)
- _deep_merge(edit_session.pdn_plan, pdn_plan)
- _deep_merge(edit_session.config_patch, config_patch)
- _deep_merge(edit_session.parameters_patch, parameters_patch)
-
-
-def _merge_floorplan_export_intent(edit_session: LayoutEditSession, intent: dict[str, Any]) -> None:
- nested_intent = intent.get("intent")
- if isinstance(nested_intent, dict):
- intent = nested_intent
- _merge_floorplan_model_patch(edit_session, intent)
- if _floorplan_bool(intent, "requiresVerilog", "requires_verilog"):
- edit_session.requires_verilog = True
-
-
-def _floorplan_patch_mapping(data: dict[str, Any], *keys: str) -> dict[str, Any]:
- present = [key for key in keys if key in data]
- if len(present) > 1:
- raise RuntimeApiError("command_failed", f"duplicate floorplan model patch field: {keys[0]}")
- if not present:
- return {}
- value = data[present[0]]
- if not isinstance(value, dict):
- raise RuntimeApiError(
- "command_failed", f"floorplan model patch field must be an object: {keys[0]}"
- )
- return deepcopy(value)
-
-
-def _deep_merge(target: dict[str, Any], patch: dict[str, Any]) -> None:
- for key, value in patch.items():
- current = target.get(key)
- if isinstance(current, dict) and isinstance(value, dict):
- _deep_merge(current, value)
- else:
- target[key] = deepcopy(value)
-
-
-def _layout_edit_place_instance_operation(operation: object) -> dict[str, Any]:
- if not isinstance(operation, dict):
- raise RuntimeApiError("invalid_request", "operation must be an object")
- if operation.get("kind") != "place_instance":
- raise RuntimeApiError("invalid_request", "unsupported layout edit operation")
-
- inst_name = _layout_edit_text(operation, "inst_name", "instName")
- cellmaster = _layout_edit_optional_text(operation, "cellmaster", "cellMaster")
- source = _layout_edit_optional_text(operation, "source")
- placement_status = (
- _layout_edit_optional_text(
- operation,
- "placement_status",
- "placementStatus",
- )
- or "preserve"
- )
- create_if_missing = _layout_edit_optional_bool(
- operation,
- "create_if_missing",
- "createIfMissing",
- default=False,
- )
- orient = _layout_edit_optional_text(operation, "orient")
- if not orient and (placement_status != "preserve" or create_if_missing):
- raise RuntimeApiError("invalid_request", "missing required operation field: orient")
- return {
- "inst_name": inst_name,
- "llx": _layout_edit_integer(operation, "llx"),
- "lly": _layout_edit_integer(operation, "lly"),
- "orient": orient,
- "cellmaster": cellmaster,
- "source": source,
- "placement_status": placement_status,
- "create_if_missing": create_if_missing,
- }
-
-
-def _layout_edit_text(operation: dict, *keys: str) -> str:
- value = _layout_edit_value(operation, *keys)
- if not isinstance(value, str) or not value.strip():
- raise RuntimeApiError("invalid_request", f"missing required operation field: {keys[0]}")
- return value
-
-
-def _layout_edit_optional_text(operation: dict, *keys: str) -> str:
- value = _layout_edit_value(operation, *keys, default="")
- if not isinstance(value, str):
- raise RuntimeApiError("invalid_request", f"operation field must be a string: {keys[0]}")
- return value
-
-
-def _layout_edit_integer(operation: dict, *keys: str) -> int:
- value = _layout_edit_value(operation, *keys)
- if isinstance(value, bool) or not isinstance(value, int):
- raise RuntimeApiError("invalid_request", f"operation field must be an integer: {keys[0]}")
- return value
-
-
-def _layout_edit_optional_bool(
- operation: dict,
- *keys: str,
- default: bool,
-) -> bool:
- value = _layout_edit_value(operation, *keys, default=default)
- if not isinstance(value, bool):
- raise RuntimeApiError("invalid_request", f"operation field must be a boolean: {keys[0]}")
- return value
-
-
-def _layout_edit_value(operation: dict, *keys: str, default: object = None):
- present = [key for key in keys if key in operation]
- if len(present) > 1:
- raise RuntimeApiError("invalid_request", f"duplicate operation field: {keys[0]}")
- return operation[present[0]] if present else default
-
-
-def _validate_layout_edit_revision(value: object, field_name: str) -> None:
- if isinstance(value, bool) or not isinstance(value, int) or value < 0:
- raise RuntimeApiError("invalid_request", f"{field_name} must be a non-negative integer")
-
-
-def _sync_layout_edit_instance_geometry(module, inst_name: str) -> dict:
- sync_instance = getattr(module, "sync_instance_geometry", None)
- if not callable(sync_instance):
- return {
- "ok": False,
- "snapshotRequired": True,
- "updatedShapeCount": 0,
- "insertedShapeCount": 0,
- "deletedShapeCount": 0,
- "missingShapeCount": 0,
- "events": [],
- }
-
- result = sync_instance(inst_name)
- if not isinstance(result, dict):
- return {
- "ok": False,
- "snapshotRequired": True,
- "updatedShapeCount": 0,
- "insertedShapeCount": 0,
- "deletedShapeCount": 0,
- "missingShapeCount": 0,
- "events": [],
- }
- return {
- "ok": bool(result.get("ok", False)),
- "snapshotRequired": bool(result.get("snapshotRequired", False)),
- "updatedShapeCount": _geometry_delta_count(result, "updatedShapeCount"),
- "insertedShapeCount": _geometry_delta_count(result, "insertedShapeCount"),
- "deletedShapeCount": _geometry_delta_count(result, "deletedShapeCount"),
- "missingShapeCount": _geometry_delta_count(result, "missingShapeCount"),
- "events": result.get("events", []) if isinstance(result.get("events", []), list) else [],
- }
-
-
-def _write_layout_edit_geometry_snapshot(module, output_dir: Path) -> Path:
- save_snapshot = getattr(module, "geometry_session_snapshot_save", None)
- if not callable(save_snapshot):
- save_snapshot = getattr(module, "geometry_snapshot_save", None)
- if not callable(save_snapshot) or not save_snapshot(output_dir=str(output_dir)):
- raise RuntimeApiError("command_failed", "failed to write layout edit geometry snapshot")
- manifest = output_dir / "geometry.manifest"
- if not manifest.is_file():
- raise RuntimeApiError("command_failed", "layout edit geometry manifest is missing")
- return manifest
-
-
-def _geometry_delta_count(delta: dict, key: str) -> int:
- value = delta.get(key, 0)
- return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
-
-
-def _publish_layout_edit_artifacts(edit_session: LayoutEditSession, workspace) -> dict[str, str]:
- targets = _layout_edit_publish_targets(edit_session.workspace_step)
- staged_workspace_data = _layout_edit_workspace_staging(edit_session, workspace)
- targets.update(staged_workspace_data["targets"])
- stage_parent = _layout_edit_stage_parent(targets.values())
- stage_root = Path(
- tempfile.mkdtemp(prefix=f".layout-edit-{edit_session.edit_session_id}-", dir=stage_parent)
- )
- staged = {key: stage_root / key / target.name for key, target in targets.items()}
- try:
- module = _db_engine_module(edit_session.db_handle)
- for staged_path in staged.values():
- staged_path.parent.mkdir(parents=True, exist_ok=True)
- module.def_save(def_path=str(staged["def"]))
- module.save_data(path=str(staged["db"]))
- module.gds_save(output_path=str(staged["gds"]))
- if not module.geometry_snapshot_save(output_dir=str(staged["geometry"])):
- raise RuntimeApiError("command_failed", "failed to export layout geometry snapshot")
- _stage_layout_edit_workspace_state(staged, staged_workspace_data, workspace)
- _stage_layout_edit_verilog(module, staged, edit_session)
- _validate_layout_edit_staging(staged, targets)
- _validate_layout_edit_workspace_staging(staged, staged_workspace_data)
- _publish_staged_layout_edit_artifacts(stage_root, staged, targets)
- except RuntimeApiError:
- raise
- except Exception as exc:
- raise RuntimeApiError("command_failed", f"failed to publish layout edit: {exc}") from exc
- finally:
- shutil.rmtree(stage_root, ignore_errors=True)
-
- _apply_layout_edit_workspace_staging(workspace, staged_workspace_data)
- artifacts = _layout_edit_published_artifacts(edit_session.workspace_step)
- artifacts.update(staged_workspace_data["artifacts"])
- return artifacts
-
-
-def _layout_edit_workspace_staging(
- edit_session: LayoutEditSession,
- workspace,
-) -> dict[str, Any]:
- targets: dict[str, Path] = {}
- json_data: dict[str, dict[str, Any]] = {}
- artifacts: dict[str, str] = {}
- result: dict[str, Any] = {
- "targets": targets,
- "json": json_data,
- "artifacts": artifacts,
- "parametersData": None,
- "flowData": None,
- }
-
- if edit_session.used_floorplan_editor:
- config_target = _floorplan_config_target(workspace)
- config_data = _read_layout_edit_json(config_target)
- _deep_merge(config_data, edit_session.config_patch)
- config_data["FloorplanPlan"] = deepcopy(edit_session.floorplan_plan)
- config_data["PdnPlan"] = deepcopy(edit_session.pdn_plan)
- targets["config"] = config_target
- json_data["config"] = config_data
- artifacts["configPath"] = str(config_target)
-
- if edit_session.parameters_patch:
- parameter_target = _workspace_path(workspace, "parameters", "path")
- if parameter_target is None:
- raise RuntimeApiError("command_failed", "workspace parameters path is missing")
- parameter_data = deepcopy(getattr(getattr(workspace, "parameters", None), "data", {}) or {})
- if not isinstance(parameter_data, dict):
- raise RuntimeApiError("command_failed", "workspace parameters are invalid")
- from chipcompiler.data.parameter_keys import normalize_parameter_dict
-
- _deep_merge(parameter_data, normalize_parameter_dict(edit_session.parameters_patch))
- targets["parameters"] = parameter_target
- json_data["parameters"] = parameter_data
- artifacts["parametersPath"] = str(parameter_target)
- result["parametersData"] = parameter_data
-
- if edit_session.requires_verilog:
- verilog_target = _path_or_none(
- _workspace_step_output_value(edit_session.workspace_step, "verilog")
- )
- if verilog_target is None:
- raise RuntimeApiError("command_failed", "layout edit output Verilog is missing")
- targets["verilog"] = verilog_target
- artifacts["verilogPath"] = str(verilog_target)
-
- flow_target = _workspace_path(workspace, "flow", "path")
- flow_data = deepcopy(getattr(getattr(workspace, "flow", None), "data", {}) or {})
- if (
- flow_target is not None
- and isinstance(flow_data, dict)
- and _mark_placement_and_later_stale(flow_data, edit_session.step_name)
- ):
- targets["flow"] = flow_target
- json_data["flow"] = flow_data
- artifacts["flowPath"] = str(flow_target)
- result["flowData"] = flow_data
-
- return result
-
-
-def _floorplan_config_target(workspace) -> Path:
- config = getattr(workspace, "config", {})
- if isinstance(config, dict):
- config_target = _path_or_none(config.get("Floorplan"))
- if config_target is not None:
- return config_target
- workspace_directory = _path_or_none(getattr(workspace, "directory", None))
- if workspace_directory is None:
- raise RuntimeApiError("command_failed", "workspace directory is missing")
- return workspace_directory / "config" / "floorplan_ecc.json"
-
-
-def _workspace_path(workspace, owner_name: str, path_name: str) -> Path | None:
- owner = getattr(workspace, owner_name, None)
- return _path_or_none(getattr(owner, path_name, None))
-
-
-def _read_layout_edit_json(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise RuntimeApiError("command_failed", f"failed to read JSON artifact: {path}") from exc
- if not isinstance(data, dict):
- raise RuntimeApiError("command_failed", f"JSON artifact must contain an object: {path}")
- return data
-
-
-def _stage_layout_edit_workspace_state(
- staged: dict[str, Path],
- workspace_staging: dict[str, Any],
- workspace,
-) -> None:
- for key, data in workspace_staging["json"].items():
- staged_path = staged[key]
- staged_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- staged_path.write_bytes(workspace_state_bytes(key, data, workspace, staged_path))
- except ValueError as exc:
- raise RuntimeApiError("command_failed", str(exc)) from exc
-
-
-def _stage_layout_edit_verilog(
- module, staged: dict[str, Path], edit_session: LayoutEditSession
-) -> None:
- if "verilog" not in staged:
- return
- save_verilog = getattr(module, "verilog_save", None)
- if not callable(save_verilog):
- raise RuntimeApiError("command_failed", "verilog_save is unavailable")
- staged["verilog"].parent.mkdir(parents=True, exist_ok=True)
- save_verilog(output_verilog=str(staged["verilog"]))
-
-
-def _validate_layout_edit_workspace_staging(
- staged: dict[str, Path],
- workspace_staging: dict[str, Any],
-) -> None:
- for key in workspace_staging["json"]:
- if not staged[key].is_file():
- raise RuntimeApiError("command_failed", f"layout edit staged {key} is missing")
- try:
- read_workspace_state(key, staged[key])
- except ValueError as exc:
- raise RuntimeApiError("command_failed", str(exc)) from exc
- if "verilog" in staged and not staged["verilog"].is_file():
- raise RuntimeApiError("command_failed", "layout edit staged Verilog is missing")
-
-
-def _apply_layout_edit_workspace_staging(workspace, workspace_staging: dict[str, Any]) -> None:
- parameters_data = workspace_staging["parametersData"]
- if parameters_data is not None:
- workspace.parameters.data = parameters_data
- flow_data = workspace_staging["flowData"]
- if flow_data is not None:
- workspace.flow.data = flow_data
-
-
-def _mark_placement_and_later_stale(flow_data: dict[str, Any], step_name: str) -> bool:
- if step_name != "Floorplan":
- return False
- steps = flow_data.get("steps")
- if not isinstance(steps, list):
- return False
- placement_index = next(
- (
- index
- for index, step in enumerate(steps)
- if isinstance(step, dict) and step.get("name") == "place"
- ),
- None,
- )
- if placement_index is None:
- return False
- changed = False
- for step in steps[placement_index:]:
- if not isinstance(step, dict):
- continue
- desired = {
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- }
- for key, value in desired.items():
- if step.get(key) != value:
- step[key] = value
- changed = True
- return changed
-
-
-def _layout_edit_publish_targets(workspace_step) -> dict[str, Path]:
- targets = {
- key: _path_or_none(_workspace_step_output_value(workspace_step, key))
- for key in ("def", "db", "gds", "geometry")
- }
- missing = [key for key, path in targets.items() if path is None]
- if missing:
- raise RuntimeApiError(
- "command_failed",
- f"layout edit output paths missing: {', '.join(missing)}",
- )
- return targets
-
-
-def _layout_edit_stage_parent(paths) -> Path:
- parents = [str(path.parent) for path in paths]
- common_parent = Path(os.path.commonpath(parents))
- common_parent.mkdir(parents=True, exist_ok=True)
- return common_parent
-
-
-def _validate_layout_edit_staging(staged: dict[str, Path], targets: dict[str, Path]) -> None:
- manifest = _geometry_manifest_path(staged["geometry"], targets["geometry"], targets)
- missing = []
- if not staged["def"].is_file():
- missing.append("def")
- if not staged["gds"].is_file():
- missing.append("gds")
- if not staged["db"].is_dir() or not any(staged["db"].iterdir()):
- missing.append("db")
- if not manifest.is_file():
- missing.append("geometry_manifest")
- if missing:
- raise RuntimeApiError(
- "command_failed",
- f"layout edit staging validation failed: {', '.join(missing)}",
- )
-
-
-def _geometry_manifest_path(
- geometry_dir: Path,
- target_geometry_dir: Path,
- targets: dict[str, Path],
-) -> Path:
- target_manifest = targets.get("geometry_manifest")
- if target_manifest is None:
- return geometry_dir / "geometry.manifest"
- try:
- return geometry_dir / target_manifest.relative_to(target_geometry_dir)
- except ValueError:
- return geometry_dir / "geometry.manifest"
-
-
-def _publish_staged_layout_edit_artifacts(
- stage_root: Path,
- staged: dict[str, Path],
- targets: dict[str, Path],
-) -> None:
- artifact_keys = tuple(targets)
- backup_dir = stage_root / "backup"
- backup_dir.mkdir()
- journal_path = stage_root / "publish.journal"
- journal_path.write_text(
- json.dumps({"state": "prepared", "artifacts": artifact_keys}),
- encoding="utf-8",
- )
- backups: list[str] = []
- published: list[str] = []
- try:
- for key in artifact_keys:
- target = targets[key]
- if target.exists() or target.is_symlink():
- os.replace(target, backup_dir / key)
- backups.append(key)
- journal_path.write_text(
- json.dumps({"state": "backed_up", "artifacts": backups}),
- encoding="utf-8",
- )
- for key in artifact_keys:
- target = targets[key]
- target.parent.mkdir(parents=True, exist_ok=True)
- os.replace(staged[key], target)
- published.append(key)
- journal_path.write_text(
- json.dumps({"state": "published", "artifacts": published}),
- encoding="utf-8",
- )
- except Exception:
- for key in reversed(published):
- _remove_layout_edit_path(targets[key])
- for key in reversed(backups):
- os.replace(backup_dir / key, targets[key])
- raise
-
-
-def _remove_layout_edit_path(path: Path) -> None:
- if path.is_dir() and not path.is_symlink():
- shutil.rmtree(path)
- else:
- path.unlink(missing_ok=True)
-
-
-def _db_engine_module(db_handle):
- module = getattr(db_handle, "engine", None)
- if module is None:
- module = getattr(db_handle, "ecc_module", None)
- if module is None:
- raise RuntimeApiError("command_failed", "layout edit DB module is unavailable")
- return module
-
-
-def _path_or_none(value: object) -> Path | None:
- if value is None or value == "":
- return None
- return Path(value)
-
-
-def _workspace_step_output_value(workspace_step, key: str):
- output = getattr(workspace_step, "output", {})
- if isinstance(output, dict):
- return output.get(key)
- return getattr(output, "def_" if key == "def" else key, None)
-
-
-def _path_text(value: object) -> str:
- path = _path_or_none(value)
- return str(path) if path is not None else ""
-
-
-def _existing_layout_def_path(value: object) -> Path | None:
- path = _path_or_none(value)
- if path is None:
- return None
- candidates = (path, Path(f"{path}.gz"))
- for candidate in candidates:
- if candidate.is_file():
- return candidate
- return None
-
-
-def _artifact_fingerprint(paths: tuple[Path, ...]) -> str:
- digest = hashlib.sha256()
- for path in paths:
- resolved_path = path.resolve()
- digest.update(str(resolved_path).encode("utf-8"))
- if resolved_path.is_dir():
- for item in sorted(resolved_path.rglob("*")):
- if not item.is_file():
- continue
- stat = item.stat()
- digest.update(str(item.relative_to(resolved_path)).encode("utf-8"))
- digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii"))
- elif resolved_path.is_file():
- stat = resolved_path.stat()
- digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii"))
- else:
- digest.update(b"missing")
- return digest.hexdigest()
-
-
-def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True):
- import chipcompiler.engine as engine_api
- import chipcompiler.rtl2gds as rtl2gds_api
-
- engine_flow = engine_api.EngineFlow(workspace=workspace)
- if not engine_flow.has_init():
- for step, tool, state in rtl2gds_api.build_rtl2gds_flow():
- engine_flow.add_step(step=step, tool=tool, state=state)
-
- if create_step_workspaces:
- engine_flow.create_step_workspaces()
- return engine_flow
-
-
-def _workspace_session_result(session: WorkspaceSession) -> dict:
- return {"workspaceId": session.workspace_id, "directory": str(session.directory)}
-
-
-def _db_ensure_result(
- *,
- workspace_id: str,
- step: str,
- active: bool,
- reused: bool,
-) -> dict:
- return {
- "workspaceId": workspace_id,
- "enabled": True,
- "active": active,
- "reused": reused,
- "step": step,
- }
-
-
-def _db_handle_is_initialized(db_handle) -> bool:
- return db_handle is not None and db_handle.has_init()
-
-
-def _close_db_handle(db_handle) -> None:
- close = getattr(db_handle, "close", None)
- if callable(close):
- close()
-
-
-def _normalize_rtl_list(rtl_list: list[str]) -> list[str]:
- result: list[str] = []
- seen = set()
- for item in rtl_list:
- path = str(item).strip()
- if not path or path in seen:
- continue
- seen.add(path)
- result.append(path)
- return result
-
-
-def _write_filelist(directory: str, rtl_paths: list[str]) -> str:
- os.makedirs(directory, exist_ok=True)
- filelist_path = os.path.join(directory, "filelist")
- with open(filelist_path, "w", encoding="utf-8") as f:
- for path in rtl_paths:
- f.write(f'"{path}"\n' if any(ch.isspace() for ch in path) else f"{path}\n")
- return filelist_path
-
-
-def _materialize_inline_pdk_json(pdk_json: Any) -> tuple[Any, Path | None]:
- if not isinstance(pdk_json, dict):
- return pdk_json, None
-
- with tempfile.NamedTemporaryFile(
- "w",
- encoding="utf-8",
- prefix="ecc-pdk-",
- suffix=".json",
- delete=False,
- ) as f:
- json.dump(pdk_json, f)
- return f.name, Path(f.name)
-
-
-def _looks_like_old_workspace(directory: str) -> bool:
- if not os.path.isdir(directory):
- return False
- home = os.path.join(directory, "home")
- if not os.path.isfile(os.path.join(home, "home.json")):
- return False
- from chipcompiler.data.workspace_config import (
- LEGACY_PARAMETERS_FILENAME,
- WORKSPACE_CONFIG_FILENAME,
- )
-
- return os.path.isfile(os.path.join(home, WORKSPACE_CONFIG_FILENAME)) or os.path.isfile(
- os.path.join(home, LEGACY_PARAMETERS_FILENAME)
- )
-
-
-def _workspace_step_from_flow(workspace, name: str):
- previous_step = None
- for flow_step in workspace.flow.data.get("steps", []):
- workspace_step = _build_workspace_step_for_info(workspace, flow_step, previous_step)
- if flow_step.get("name") == name:
- return workspace_step
- if workspace_step is not None:
- previous_step = workspace_step
- return None
-
-
-def _build_workspace_step_for_info(workspace, flow_step: dict, previous_step):
- step_name = flow_step.get("name")
- tool = flow_step.get("tool")
- if not step_name or not tool:
- return None
-
- if previous_step is None:
- input_def = workspace.design.origin_def
- input_verilog = workspace.design.origin_verilog
- input_db = None
- else:
- input_def = previous_step.output.def_ or ""
- input_verilog = previous_step.output.verilog or ""
- input_db = previous_step.output.db or ""
-
- builder = _load_tool_builder(tool)
- if builder is None or not hasattr(builder, "build_step"):
- return None
-
- return builder.build_step(
- workspace=workspace,
- step_name=step_name,
- input_def=input_def,
- input_verilog=input_verilog,
- input_db=input_db,
- )
-
-
-def _load_tool_builder(tool: str):
- import importlib
-
- module_alias = {
- "klayout": "klayout_tool",
- "dreamplace": "ecc_dreamplace",
- "sizer": "ecc_sizer",
- }
- module_name = module_alias.get(tool, tool)
- return importlib.import_module(f"chipcompiler.tools.{module_name}.builder")
-
-
-def _init_db_engine_for_workspace_step(engine_flow, workspace_step):
- engine_db = getattr(engine_flow, "engine_db", None)
- if engine_db is None:
- from chipcompiler.engine import EngineDB
-
- engine_db = EngineDB(workspace=engine_flow.workspace)
- engine_flow.engine_db = engine_db
- elif engine_db.has_init():
- return True
-
- return engine_db.create_db_engine(step=workspace_step)
-
-
-def _success_state():
- from chipcompiler.data import StateEnum
-
- return StateEnum.Success
-
-
-def _state_value(state: Any) -> str:
- return getattr(state, "value", str(state))
-
-
-def _run_engine_flow_steps(engine_flow, *, rerun: bool, observer) -> bool:
- run_steps = engine_flow.run_steps
- if observer is not None and _callable_accepts_keyword(run_steps, "observer"):
- return run_steps(rerun=rerun, observer=observer)
- return run_steps(rerun=rerun)
-
-
-def _run_engine_flow_step(engine_flow, workspace_step, *, rerun: bool, observer):
- run_step = engine_flow.run_step
- if observer is not None and _callable_accepts_keyword(run_step, "observer"):
- return run_step(workspace_step, rerun=rerun, observer=observer)
- return run_step(workspace_step, rerun=rerun)
-
-
-def _callable_accepts_keyword(callback, keyword: str) -> bool:
- try:
- parameters = inspect.signature(callback).parameters.values()
- except (TypeError, ValueError):
- return False
- return any(
- parameter.name == keyword or parameter.kind == inspect.Parameter.VAR_KEYWORD
- for parameter in parameters
- )
diff --git a/chipcompiler/runtime/workspace_config_io.py b/chipcompiler/runtime/workspace_config_io.py
deleted file mode 100644
index 611fed01..00000000
--- a/chipcompiler/runtime/workspace_config_io.py
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env python
-
-"""Workspace configuration IO helpers for the runtime API.
-
-Config-staging policy extracted from the runtime API: RPC creation payload
-conversion and the format-agnostic staging used by layout-edit publish.
-"""
-
-import json
-import tomllib
-from pathlib import Path
-from typing import Any
-
-
-def canonical_request_parameters(parameters: dict[str, Any] | None) -> dict[str, Any]:
- """Normalize an RPC creation payload to the canonical flat vocabulary.
-
- GUI flat keys (including the positional geometry aliases) and any legacy
- long keys are both converted here; the result is merged verbatim by the
- workspace layer.
- """
- if not parameters:
- return {}
- from chipcompiler.data.parameter_keys import geometry_to_parameters
-
- return geometry_to_parameters(parameters)
-
-
-def read_workspace_config_toml(path: Path) -> None:
- """Validate that a staged workspace config artifact is parseable TOML."""
- try:
- with open(path, "rb") as file:
- tomllib.load(file)
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise ValueError(f"failed to read workspace config artifact: {path}") from exc
-
-
-def workspace_config_bytes(data: dict[str, Any], workspace) -> bytes:
- """Render a workspace configuration TOML document for staging."""
- from chipcompiler.data.workspace_config import render_workspace_config
-
- workspace_dir = getattr(workspace, "directory", None)
- if workspace_dir is None:
- raise ValueError("workspace directory is missing")
- payload = dict(data)
- flow = payload.pop("_flow", None)
- return render_workspace_config(Path(workspace_dir), payload, flow)
-
-
-def workspace_state_bytes(kind: str, data: dict[str, Any], workspace, target: Path) -> bytes:
- """Serialize one staged workspace-state artifact in its persisted format.
-
- The parameters artifact persists as the TOML workspace config when its
- target carries a .toml suffix; every other artifact is JSON.
- """
- if kind == "parameters" and target.suffix == ".toml":
- return workspace_config_bytes(data, workspace)
- return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8")
-
-
-def read_workspace_state(kind: str, path: Path) -> None:
- """Validate that a staged artifact parses in its persisted format."""
- if kind == "parameters" and path.suffix == ".toml":
- read_workspace_config_toml(path)
- return
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise ValueError(f"failed to read JSON artifact: {path}") from exc
- if not isinstance(data, dict):
- raise ValueError(f"JSON artifact must contain an object: {path}")
diff --git a/chipcompiler/tools/ecc/subflow.py b/chipcompiler/tools/ecc/subflow.py
index 626ed157..256f39b1 100644
--- a/chipcompiler/tools/ecc/subflow.py
+++ b/chipcompiler/tools/ecc/subflow.py
@@ -193,7 +193,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None
self.save()
- from chipcompiler.runtime.subflow_events import publish_subflow_stage
+ from chipcompiler.engine.subflow_events import publish_subflow_stage
publish_subflow_stage(self.workspace, self.workspace_step, step_dict)
diff --git a/chipcompiler/tools/ecc_sizer/subflow.py b/chipcompiler/tools/ecc_sizer/subflow.py
index b3cd76a8..79c43fd8 100644
--- a/chipcompiler/tools/ecc_sizer/subflow.py
+++ b/chipcompiler/tools/ecc_sizer/subflow.py
@@ -143,7 +143,7 @@ def update_step(
step_dict["info"] = info
self.save()
- from chipcompiler.runtime.subflow_events import publish_subflow_stage
+ from chipcompiler.engine.subflow_events import publish_subflow_stage
publish_subflow_stage(self.workspace, self.workspace_step, step_dict)
diff --git a/chipcompiler/tools/yosys/subflow.py b/chipcompiler/tools/yosys/subflow.py
index eca2cf7b..861192a9 100644
--- a/chipcompiler/tools/yosys/subflow.py
+++ b/chipcompiler/tools/yosys/subflow.py
@@ -109,7 +109,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None
self.save()
- from chipcompiler.runtime.subflow_events import publish_subflow_stage
+ from chipcompiler.engine.subflow_events import publish_subflow_stage
publish_subflow_stage(self.workspace, self.workspace_step, step_dict)
diff --git a/chipcompiler/tools/yosys_lec/subflow.py b/chipcompiler/tools/yosys_lec/subflow.py
index be356dea..97fdf3da 100644
--- a/chipcompiler/tools/yosys_lec/subflow.py
+++ b/chipcompiler/tools/yosys_lec/subflow.py
@@ -88,7 +88,7 @@ def update_step(self, step_name: str, state: str | StateEnum, info: dict | None
step_dict["info"] = info
self.save()
- from chipcompiler.runtime.subflow_events import publish_subflow_stage
+ from chipcompiler.engine.subflow_events import publish_subflow_stage
publish_subflow_stage(self.workspace, self.workspace_step, step_dict)
break
diff --git a/docs/development.cn.md b/docs/development.cn.md
index 0b14b99a..d51857f7 100644
--- a/docs/development.cn.md
+++ b/docs/development.cn.md
@@ -309,7 +309,7 @@ chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植)
- 错误记录用 `core/records.py::error_record(...)`,产出 `{"kind": "error", "error": "<机器可读错误码>", ...}`;TEXT 模式下由 `render_error` 打成 `[error]` 块。错误码是稳定契约(如 `missing_config`、`run_exists`、`unknown_parameter`、`invalid_value`),测试会对它们断言。
- 给用户的「下一步」提示统一用 `core/output.py::disclosure_cmd("ecc status", project, run_id)` 生成可复制的完整命令,记录里放在 `inspect` / `log_cmd` / `run` 等字段。
-`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc rpc serve` 与 `ecc layout-image` 有意不使用 records 渲染器输出模式。
+`ecc version` 直接格式化版本元数据;另有一个隐藏的 `--json` 选项(单对象、版本专用 schema)预留给桌面应用,不出现在 `--help` 中。`ecc layout-image` 有意不使用 records 渲染器输出模式。
### 新增一个命令
@@ -422,7 +422,7 @@ config_param(
#### 扩展签核(`ecc signoff inspect/export`)
-- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `runtime/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`RuntimeApiError` → `signoff_incomplete`)。
+- **CLI 层**:`cli/commands/signoff.py` + `cli/command_handlers/signoff.py`。`inspection/discovery.py::resolve_loaded_workspace()` 在选定项目中解析受管 `--workspace NAME`(或唯一活跃 workspace)。inspect 复用 `engine/signoff_export.py::inspect_signoff_package`(blocked 也 rc=0);export 复用 `export_signoff_package_archive`(`SignoffExportError` → `signoff_incomplete`)。
- **引擎层**:`chipcompiler/engine/signoff/` 包负责签核收集器 `SignoffPackageCollector`,以及就绪度检查和归档导出所使用的包级 API。
#### 扩展报告(`ecc report summary/qor/checklist/step`)
@@ -432,10 +432,6 @@ config_param(
- `engine/signoff/report_checklist.py`:只读渲染 `home/checklist.json`(不合法时报 unavailable,绝不回写文件)。
- CLI:`cli/commands/report.py` + `cli/command_handlers/report.py`;workspace 解析复用 `inspection/discovery.py`(`resolve_workspace_path` 是无副作用核心,`resolve_command_workspace` 是核心加 `load_workspace`;signoff、report 与只读的 status/log/config 共用)。
-#### 扩展 RPC(`ecc rpc serve`)
-
-`rpc serve --stdio` 启动 JSON-RPC 2.0 sidecar(`chipcompiler/runtime/stdio_server.py`)。方法在 `chipcompiler/runtime/methods.py::RUNTIME_METHODS` 声明(`method_name` + pydantic `request_model` + `handler_name`),handler 实现在 `chipcompiler/runtime/workspace_api.py`,由 `runtime/server.py` 统一挂载;协议细节见 [rpc-guide.md](rpc-guide.md)。新增方法 = 加一个 `RuntimeMethodSpec` + 对应 API 方法 + 请求模型,无需改 CLI 层。
-
#### 扩展项目声明(`ecc project *` / `ecc workspace refresh`)
- `ecc project set/unset/add/remove/show` 的可编辑键在 `cli/project/config_fields.py::PROJECT_FIELDS` 声明(`key` / TOML 表 / 字段名 / 类型 / `list_value`)。加一个字段五个子命令自动生效;`add`/`remove` 硬性只支持 `design.rtl`(其余键报 `unsupported_project_collection`)。
@@ -670,6 +666,5 @@ Python 层调试可直接调同一 CLI 模块:
## 相关文档
-- [rpc-guide.md](rpc-guide.md) - RPC sidecar 协议
- [examples/](examples/) - 示例项目与 CLI 用法
- [English version](development.md)
diff --git a/docs/development.md b/docs/development.md
index bb23cba1..fc48e199 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -276,8 +276,7 @@ chipcompiler/cli/commands/ # typer command definition layer (thin)
├── project_config.py # project sub-app (set/unset/add/remove/show)
├── workspace.py # workspace sub-app (refresh)
├── signoff.py # signoff sub-app (inspect/export)
- ├── report.py # report sub-app (summary/qor/checklist/step)
- └── rpc.py # rpc sub-app (serve)
+ └── report.py # report sub-app (summary/qor/checklist/step)
chipcompiler/cli/command_handlers/ # business logic layer (stateful / heavy)
├── project.py # init / check / run / migrate / workspace refresh (preset resolution and environment preflight)
├── inspect.py # status / log / config
@@ -350,8 +349,8 @@ Commands dispatched through `execute_command()` use a "list of records":
`ecc version` formats version metadata directly; it also has a hidden `--json`
flag (a single object with a version-specific schema) reserved for the desktop
-app and kept out of `--help`. `ecc rpc serve` and `ecc layout-image`
-intentionally do not use record-renderer output modes.
+app and kept out of `--help`. `ecc layout-image` intentionally does not use
+record-renderer output modes.
### Adding A New Command
@@ -554,9 +553,9 @@ required by doctor.
- **CLI layer**: `cli/commands/signoff.py` + `cli/command_handlers/signoff.py`.
`inspection/discovery.py::resolve_loaded_workspace()` resolves a managed
`--workspace NAME` in the selected project (or its sole active workspace).
- inspect reuses `runtime/signoff_export.py::inspect_signoff_package` (blocked
+ inspect reuses `engine/signoff_export.py::inspect_signoff_package` (blocked
still exits 0); export reuses `export_signoff_package_archive`
- (`RuntimeApiError` → `signoff_incomplete`).
+ (`SignoffExportError` → `signoff_incomplete`).
- **Engine layer**: the `chipcompiler/engine/signoff/` package owns the signoff
collector `SignoffPackageCollector` and the package-export APIs used by
readiness inspection and archive generation.
@@ -586,17 +585,6 @@ required by doctor.
side-effect-free core, `resolve_command_workspace` = core + `load_workspace`;
shared by signoff, report, and the read-only status/log/config commands).
-#### Extending the RPC (`ecc rpc serve`)
-
-`rpc serve --stdio` starts the JSON-RPC 2.0 sidecar
-(`chipcompiler/runtime/stdio_server.py`). Methods are declared in
-`chipcompiler/runtime/methods.py::RUNTIME_METHODS` (`method_name` + a pydantic
-`request_model` + `handler_name`), handler implementations live in
-`chipcompiler/runtime/workspace_api.py`, and `runtime/server.py` mounts them
-uniformly; protocol details in [rpc-guide.md](rpc-guide.md). Adding a
-method = one `RuntimeMethodSpec` + the matching API method + a request model;
-no CLI-layer changes needed.
-
#### Extending project declarations (`ecc project *` / `ecc workspace refresh`)
- The editable keys of `ecc project set/unset/add/remove/show` are declared in
@@ -890,6 +878,5 @@ standards).
## Related Documentation
-- [RPC Guide](rpc-guide.md) - RPC sidecar protocol
- [Examples](examples/) - Example projects and CLI usage
- [中文开发指南](development.cn.md)
diff --git a/docs/index.md b/docs/index.md
index 72997594..3988f649 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,10 +11,9 @@ The `ecc` command-line tool ships bilingual guides (`.en.md` / `.cn.md`):
- First project, the 15-step `rtl2gds` flow, signoff package, and reports
- Tuning parameters, workspaces, and rerun scenarios
- **[CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md)** / **[中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md)** - All currently supported commands
- - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`rpc`/`layout-image`
+ - Every command and option: `init`/`check`/`run`/`status`/`log`/`config`/`doctor`/`param`/`pdk`/`project`/`workspace`/`signoff`/`report`/`layout-image`
- Run selectors (`--resume`/`--from`/`--to`/`--only`), error-code reference, end-to-end workflows
- **[CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md)** / **[中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md)** - `ecc.toml`, workspace files, and the parameter system
-- **[RPC Guide](rpc-guide.md)** - Private JSON-RPC runtime sidecar protocol (`ecc rpc serve`)
## Core Documentation
@@ -56,7 +55,6 @@ ChipCompiler supports various EDA file formats. Technical specifications for par
- **Look up an `ecc` command or option** → [CLI User Guide](../chipcompiler/docs/ecc-user-guide.en.md) / [中文用户指南](../chipcompiler/docs/ecc-user-guide.cn.md)
- **Understand `ecc.toml` / workspace files / parameters** → [CLI Config Reference](../chipcompiler/docs/ecc-config-ref.en.md) / [中文配置参考](../chipcompiler/docs/ecc-config-ref.cn.md)
- **Extend the CLI with new commands** → [CLI Dev Guide](development.md#extending-the-cli)
-- **Use legacy workspace commands** → [RPC Guide](rpc-guide.md)
- **Set up development environment** → [Development Guide](development.md)
- **Create a release** → [Release Guide](release.md)
- **Add new tools** → [Development Guide - Adding EDA Tools](development.md#add-a-new-eda-tool)
diff --git a/docs/rpc-guide.md b/docs/rpc-guide.md
deleted file mode 100644
index e8225fb8..00000000
--- a/docs/rpc-guide.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# Runtime Sidecar RPC Guide
-
-Workspace operations are exposed through the private ECC runtime sidecar:
-
-```bash
-ecc rpc serve --stdio
-```
-
-Persistent ECC DB reuse is disabled by default. To expose the explicit DB
-lifecycle methods, start the sidecar with:
-
-```bash
-ecc rpc serve --stdio --persistent-db
-```
-
-The sidecar speaks JSON-RPC 2.0 over stdio. Each JSON-RPC payload is framed with
-a `Content-Length` header. Stdout is reserved for framed protocol messages;
-diagnostics and tool output belong on stderr.
-
-The public CLI supports only `ecc workspace refresh NAME` from this resource
-area. It reconstructs a workspace already declared in `project.json` from the
-current `ecc.toml`, without executing the flow. The legacy workspace create/run
-commands and their custom server-shaped JSON envelope are not supported.
-Project commands such as `ecc init`, `ecc run`, `ecc status`, `ecc config`, and
-`ecc param` remain ordinary stateless CLI commands.
-
-## Framing
-
-Each request is UTF-8 JSON preceded by a byte length:
-
-```text
-Content-Length: 63
-
-{"jsonrpc":"2.0","method":"rpc.ping","params":{},"id":"ping-1"}
-```
-
-The server returns another framed payload:
-
-```text
-Content-Length: 52
-
-{"jsonrpc":"2.0","result":{"ok":true},"id":"ping-1"}
-```
-
-## Handshake
-
-Call `rpc.hello` first to verify protocol compatibility and discover the
-first-slice method list:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "rpc.hello",
- "params": {
- "version": 1
- },
- "id": "hello-1"
-}
-```
-
-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`.
-
-## Open A Workspace
-
-Open an existing workspace directory:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "workspace.open",
- "params": {
- "directory": "/path/to/gcd"
- },
- "id": "open-1"
-}
-```
-
-The result returns a session identifier:
-
-```json
-{
- "workspaceId": "workspace-1",
- "directory": "/path/to/gcd"
-}
-```
-
-Follow-up workspace and flow calls use `workspaceId`. They do not take the
-workspace directory again unless the method explicitly documents a directory
-parameter.
-
-Opening or creating a workspace does not initialize persistent native DB state.
-Persistent DB reuse starts only after an explicit `db.ensure` call in a sidecar
-process started with `--persistent-db`.
-
-## Create A Workspace
-
-Create accepts the workspace directory, PDK name, optional PDK paths, design
-parameters, and optional input files:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "workspace.create",
- "params": {
- "directory": "/path/to/gcd",
- "pdk": "ics55",
- "pdkRoot": "/path/to/icsprout55-pdk",
- "parameters": {
- "design": "gcd",
- "top_module": "gcd",
- "clock": "clk",
- "frequency_max": 100
- },
- "originVerilog": "/path/to/gcd.v",
- "rtlList": ["/path/to/gcd.v"]
- },
- "id": "create-1"
-}
-```
-
-If `filelist` is omitted and `rtlList` is present, ECC writes a workspace-local
-filelist before creating the workspace.
-
-## Inspect A Workspace
-
-Use the returned `workspaceId` to inspect session state:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "workspace.home",
- "params": {
- "workspaceId": "workspace-1"
- },
- "id": "home-1"
-}
-```
-
-Tool-specific step information is available through `workspace.info`:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "workspace.info",
- "params": {
- "workspaceId": "workspace-1",
- "step": "Synthesis",
- "id": "layout"
- },
- "id": "info-1"
-}
-```
-
-Common info ids include `views`, `layout`, `metrics`, `subflow`, `analysis`,
-`maps`, `checklist`, `sta`, and `config`.
-
-## Mutating Workspace Calls
-
-The runtime serializes mutating calls for the same workspace session. Supported
-first-slice mutation methods are:
-
-- `workspace.refresh_config`
-- `workspace.sync_config`
-- `workspace.reset_flow`
-- `flow.run`
-- `flow.run_step`
-- `workspace.close`
-
-`workspace.sync_config` requires `configPath` to be inside the workspace
-`config/` directory:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "workspace.sync_config",
- "params": {
- "workspaceId": "workspace-1",
- "configPath": "/path/to/gcd/config/route.json"
- },
- "id": "sync-1"
-}
-```
-
-Run a single step:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "flow.run_step",
- "params": {
- "workspaceId": "workspace-1",
- "step": "Synthesis",
- "rerun": false
- },
- "id": "step-1"
-}
-```
-
-## Persistent DB Lifecycle
-
-Persistent DB lifecycle calls are private runtime capabilities and are available
-only when the sidecar was started with `--persistent-db`.
-
-Ensure a session-scoped DB handle:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "db.ensure",
- "params": {
- "workspaceId": "workspace-1",
- "step": "Floorplan"
- },
- "id": "db-ensure-1"
-}
-```
-
-The `step` field is optional. When omitted, ECC uses the existing flow rule for
-selecting the first unfinished step. A successful result reports whether the
-handle is active and whether an existing handle was reused:
-
-```json
-{
- "workspaceId": "workspace-1",
- "enabled": true,
- "active": true,
- "reused": false,
- "step": "Floorplan"
-}
-```
-
-Release the active session DB handle:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "db.release",
- "params": {
- "workspaceId": "workspace-1"
- },
- "id": "db-release-1"
-}
-```
-
-`db.release` is idempotent and returns `released: false` when the session has no
-active DB handle. Workspace refresh, changed config sync, reset, rerun, close,
-replacement, and shutdown release stale handles. `flow.run` and `flow.run_step`
-reuse and capture DB state only when the session already has an active handle
-from `db.ensure`; otherwise their DB use remains transient.
-
-## Shutdown
-
-End the sidecar with `rpc.shutdown`:
-
-```json
-{
- "jsonrpc": "2.0",
- "method": "rpc.shutdown",
- "id": "shutdown-1"
-}
-```
-
-The server closes workspace sessions and exits after the response is written.
-
-## Errors
-
-JSON-RPC validation errors use standard JSON-RPC error objects. Runtime errors
-use ECC-specific code strings in the JSON-RPC error `message` field, with
-human-readable details in `data.message` when available.
-
-Common runtime error messages:
-
-- `unsupported_version`: `rpc.hello` used an incompatible protocol version.
-- `workspace_session_not_found`: the supplied `workspaceId` is unknown or
- closed.
-- `invalid_request`: params are missing required fields or include unknown
- fields.
-- `command_failed`: workspace or flow execution failed.
diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md
index d678d39c..b033d13a 100644
--- a/docs/specification/cli-design.md
+++ b/docs/specification/cli-design.md
@@ -136,7 +136,6 @@ Current implementation status:
| `ecc report summary/qor/checklist/step` | `--plain` |
| `ecc doc` | `--plain` |
| `ecc version` | hidden `--json` only (desktop app contract) |
-| `ecc rpc serve` | none (machine protocol) |
| `ecc layout-image` | none (tool invocation; produces a file) |
When `--plain` is given, the implementation renders plain records; otherwise it
@@ -294,10 +293,10 @@ The command graph follows these rules; new commands must follow them too:
registration in `cli/commands/`, framework in `cli/core/`, read-only probing
in `cli/inspection/`, all rendering in `cli/rendering/` behind a single
registry keyed by full command path (top-level name, or `group:sub`).
-- **Frozen surfaces.** The GUI invokes `ecc rpc serve --stdio
- [--persistent-db]`, `ecc version --json` (schema: `schema_version`, `runtime`,
- `ecc`, `dreamplace`, `ecc_tools`, `tools`), the `ecc --version` single line,
- and `ecc layout-image --gds --image ` as subprocess contracts.
+- **Frozen surfaces.** The GUI invokes `ecc version --json` (schema:
+ `schema_version`, `runtime`, `ecc`, `dreamplace`, `ecc_tools`, `tools`), the
+ `ecc --version` single line, and
+ `ecc layout-image --gds --image ` as subprocess contracts.
Additive optional flags are allowed; these names, flags, and output schemas
must not change.
@@ -427,56 +426,6 @@ version` prints fixed-order text lines for `ecc`, `dreamplace`, `ecc_tools`, and
reported as `unknown`, except the `ecc` field may fall back to the source
package `__version__`.
-### Runtime Sidecar RPC
-
-The old workspace create/run compatibility commands are not exposed as a public
-CLI namespace. The supported runtime session surface is the private stdio
-sidecar:
-
-```bash
-ecc rpc serve --stdio
-ecc rpc serve --stdio --persistent-db
-```
-
-The sidecar uses JSON-RPC 2.0 payloads framed with `Content-Length` headers.
-After `workspace.create` or `workspace.open`, follow-up calls use the returned
-`workspaceId` rather than repeatedly passing the workspace directory. The
-default sidecar does not advertise or persist native DB handles.
-
-First-slice runtime methods include:
-
-```text
-rpc.hello
-rpc.ping
-rpc.shutdown
-workspace.create
-workspace.open
-workspace.close
-workspace.home
-workspace.info
-workspace.refresh_config
-workspace.sync_config
-workspace.reset_flow
-flow.run
-flow.run_step
-```
-
-`--persistent-db` is an opt-in process capability. When enabled, `rpc.hello`
-also advertises:
-
-```text
-db.ensure
-db.release
-```
-
-These DB methods are not part of the default first-slice method list. They start
-and stop session-scoped DB reuse explicitly; `workspace.open`,
-`workspace.create`, `flow.run`, and `flow.run_step` must not start persistent DB
-reuse for a session that has not called `db.ensure`.
-
-The former custom workspace JSON object is not part of the supported output
-contract. See `docs/rpc-guide.md` for framing examples and method payloads.
-
## Output Contracts
### Summary Line Format
@@ -727,7 +676,6 @@ Success criteria:
- [x] `ecc config`
- [x] Managed workspace selection for inspection commands with `--workspace NAME`
- [x] Parameter overrides with `ecc param` and `ecc run --set`
-- [x] Private runtime sidecar under `ecc rpc serve --stdio`
- [ ] Run tags and run comparison basics
Success criteria:
diff --git a/ecc.spec b/ecc.spec
index 0f1dba9b..1f256a20 100644
--- a/ecc.spec
+++ b/ecc.spec
@@ -164,10 +164,6 @@ def collect_ecc_resources():
return datas
-def collect_jsonrpcserver_resources():
- return collect_data_files("jsonrpcserver")
-
-
def collect_dreamplace_thirdparty_files():
datas = []
thirdparty_root = ECC_DIR / "chipcompiler" / "thirdparty" / "ecc-dreamplace"
@@ -283,7 +279,6 @@ datas.extend(dreamplace_datas)
datas.extend(torch_datas)
datas.extend(collect_required_metadata())
datas.extend(collect_ecc_resources())
-datas.extend(collect_jsonrpcserver_resources())
datas.extend(collect_dreamplace_thirdparty_files())
datas.extend(collect_doc_guides())
diff --git a/pyproject.toml b/pyproject.toml
index 514f5ab9..48bd95bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,6 @@ dependencies = [
"ecc-dreamplace==0.1.0a7",
"ecc-tools-bin==0.1.0a13",
"fastapi>=0.109",
- "jsonrpcserver>=5.0.9",
"klayout>=0.30.2",
"matplotlib>=3.4",
"numpy>=1.21",
diff --git a/test/cli/test_cli_module_layout.py b/test/cli/test_cli_module_layout.py
index 50b08154..1405ddcb 100644
--- a/test/cli/test_cli_module_layout.py
+++ b/test/cli/test_cli_module_layout.py
@@ -51,7 +51,7 @@ def test_core_modules_live_under_core_package():
def test_command_registration_modules_live_under_commands_package():
- for module_name in ("project", "doctor", "param", "pdk", "signoff", "report", "rpc"):
+ for module_name in ("project", "doctor", "param", "pdk", "signoff", "report"):
module = importlib.import_module(f"chipcompiler.cli.commands.{module_name}")
assert module.__name__ == f"chipcompiler.cli.commands.{module_name}"
diff --git a/test/cli/test_rpc_cli.py b/test/cli/test_rpc_cli.py
deleted file mode 100644
index 0a3781eb..00000000
--- a/test/cli/test_rpc_cli.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from chipcompiler.cli import main as cli_main
-
-
-def test_rpc_help_returns_zero_and_lists_serve(capsys):
- rc = cli_main.run(["rpc", "--help"])
-
- out = capsys.readouterr().out
- assert rc == 0
- assert "serve" in out
-
-
-def test_rpc_serve_help_returns_zero_and_lists_stdio(capsys):
- rc = cli_main.run(["rpc", "serve", "--help"])
-
- out = capsys.readouterr().out
- assert rc == 0
- assert "--stdio" in out
- assert "--persistent-db" in out
-
-
-def test_rpc_serve_requires_stdio(capsys):
- rc = cli_main.run(["rpc", "serve"])
-
- assert rc != 0
- assert "--stdio" in capsys.readouterr().err
-
-
-def test_run_help_does_not_list_persistent_db(capsys):
- rc = cli_main.run(["run", "--help"])
-
- assert rc == 0
- assert "--persistent-db" not in capsys.readouterr().out
diff --git a/test/cli/test_typer_cli.py b/test/cli/test_typer_cli.py
index 54efc15e..aa0c7c92 100644
--- a/test/cli/test_typer_cli.py
+++ b/test/cli/test_typer_cli.py
@@ -29,7 +29,6 @@ def test_root_help_returns_zero_and_lists_commands(capsys):
"workspace",
"signoff",
"report",
- "rpc",
):
assert command in out
for removed_command in ("metrics", "artifacts", "diagnose"):
@@ -208,19 +207,11 @@ def fake_run(command_input, ctx):
}
-def test_rpc_routes_through_root_typer(monkeypatch):
- seen = {}
-
- def fake_invoke(argv):
- seen["argv"] = argv
- return 17
-
- monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke)
-
+def test_removed_rpc_command_returns_unknown_command(capsys):
rc = cli_main.run(["rpc", "serve", "--stdio"])
- assert rc == 17
- assert seen["argv"] == ["rpc", "serve", "--stdio"]
+ assert rc != 0
+ assert "No such command" in capsys.readouterr().err
def test_run_default_argv_uses_sys_argv(monkeypatch):
@@ -230,13 +221,13 @@ def fake_invoke(argv):
seen["argv"] = argv
return 17
- monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "rpc", "serve", "--stdio"])
+ monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "status"])
monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke)
rc = cli_main.run()
assert rc == 17
- assert seen["argv"] == ["rpc", "serve", "--stdio"]
+ assert seen["argv"] == ["status"]
def test_main_exits_with_run_code(monkeypatch):
@@ -246,7 +237,7 @@ def fake_invoke(argv):
seen["argv"] = argv
return 17
- monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "rpc", "serve", "--stdio"])
+ monkeypatch.setattr(cli_main.sys, "argv", ["ecc", "status"])
monkeypatch.setattr("chipcompiler.cli.app.invoke_typer_app", fake_invoke)
try:
@@ -257,7 +248,7 @@ def fake_invoke(argv):
raise AssertionError("main() did not exit")
assert code == 17
- assert seen["argv"] == ["rpc", "serve", "--stdio"]
+ assert seen["argv"] == ["status"]
def test_old_top_level_workspace_form_is_root_parser_error(capsys):
diff --git a/test/data/test_workspace.py b/test/data/test_workspace.py
index 198bc441..8fae64eb 100644
--- a/test/data/test_workspace.py
+++ b/test/data/test_workspace.py
@@ -521,7 +521,7 @@ def test_build_flow_for_dynamic_workspace_initializes_step_metadata_files(
},
)
- from chipcompiler.runtime.workspace_api import build_flow_for_workspace
+ from chipcompiler.engine.workspace_flow import build_flow_for_workspace
build_flow_for_workspace(workspace)
diff --git a/test/runtime/test_subflow_events.py b/test/engine/test_subflow_events.py
similarity index 92%
rename from test/runtime/test_subflow_events.py
rename to test/engine/test_subflow_events.py
index 88814109..9143c726 100644
--- a/test/runtime/test_subflow_events.py
+++ b/test/engine/test_subflow_events.py
@@ -2,7 +2,7 @@
import pytest
-from chipcompiler.runtime import subflow_events
+from chipcompiler.engine import subflow_events
def test_interrupted_subflow_write_failure_restores_ongoing_state(monkeypatch, tmp_path):
diff --git a/test/packaging/test_cli_entrypoint.py b/test/packaging/test_cli_entrypoint.py
index de400ac1..4f17d158 100644
--- a/test/packaging/test_cli_entrypoint.py
+++ b/test/packaging/test_cli_entrypoint.py
@@ -12,14 +12,14 @@ def test_ecc_console_script_in_pyproject(self):
assert data["project"]["scripts"]["ecc"] == "chipcompiler.cli.main:main"
assert set(data["project"]["scripts"]) == {"ecc"}
- def test_pyinstaller_spec_collects_jsonrpcserver_data_files(self):
+ def test_pyinstaller_spec_excludes_removed_jsonrpcserver_data_files(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 'collect_data_files("jsonrpcserver")' in source
+ assert 'collect_data_files("jsonrpcserver")' not in source
def test_pyinstaller_spec_filters_payloads_before_analysis(self):
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
diff --git a/test/runtime/test_events.py b/test/runtime/test_events.py
deleted file mode 100644
index ca2b12e1..00000000
--- a/test/runtime/test_events.py
+++ /dev/null
@@ -1,156 +0,0 @@
-import os
-import sys
-import threading
-from contextlib import nullcontext
-
-import pytest
-
-from chipcompiler.runtime.events import redirect_stdout_to_stderr
-from chipcompiler.utility.log import capture_stdio_to_file
-
-
-def test_redirect_stdout_to_stderr_restores_fd_1_and_fd_2(tmp_path):
- stdout_target = tmp_path / "stdout.txt"
- stderr_target = tmp_path / "stderr.txt"
- redirected_target = tmp_path / "redirected.txt"
-
- saved_stdout_fd = os.dup(1)
- saved_stderr_fd = os.dup(2)
- with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file:
- os.dup2(stdout_file.fileno(), 1)
- os.dup2(stderr_file.fileno(), 2)
- try:
- with redirect_stdout_to_stderr(), open(redirected_target, "wb") as redirected_file:
- os.dup2(redirected_file.fileno(), 2)
- os.write(1, b"captured stdout\n")
- os.write(2, b"captured stderr\n")
-
- os.write(1, b"restored stdout\n")
- os.write(2, b"restored stderr\n")
- finally:
- os.dup2(saved_stdout_fd, 1)
- os.dup2(saved_stderr_fd, 2)
- os.close(saved_stdout_fd)
- os.close(saved_stderr_fd)
- sys.stdout = sys.__stdout__
- sys.stderr = sys.__stderr__
-
- assert stdout_target.read_text() == "restored stdout\n"
- assert stderr_target.read_text() == "captured stdout\nrestored stderr\n"
- assert redirected_target.read_text() == "captured stderr\n"
-
-
-@pytest.mark.parametrize("error", [None, RuntimeError("failed"), SystemExit(0)])
-def test_capture_stdio_to_file_restores_fd_1_and_fd_2(tmp_path, error):
- original_fds = (os.dup(1), os.dup(2))
- stdout_target = tmp_path / "stdout.txt"
- stderr_target = tmp_path / "stderr.txt"
- step_log = tmp_path / "step.log"
- with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file:
- os.dup2(stdout_file.fileno(), 1)
- os.dup2(stderr_file.fileno(), 2)
- try:
- with (
- pytest.raises(type(error)) if error else nullcontext(),
- capture_stdio_to_file(str(step_log)),
- ):
- os.write(1, b"step stdout\n")
- os.write(2, b"step stderr\n")
- if error:
- raise error
- os.write(1, b"restored stdout\n")
- os.write(2, b"restored stderr\n")
- finally:
- os.dup2(original_fds[0], 1)
- os.dup2(original_fds[1], 2)
- os.close(original_fds[0])
- os.close(original_fds[1])
-
- assert step_log.read_text() == "step stdout\nstep stderr\n"
- assert stdout_target.read_text() == "restored stdout\n"
- assert stderr_target.read_text() == "restored stderr\n"
-
-
-def test_capture_stdio_to_file_serializes_process_fds(tmp_path):
- first_entered = threading.Event()
- second_attempted = threading.Event()
- second_entered = threading.Event()
- release_first = threading.Event()
-
- def capture_first():
- with capture_stdio_to_file(str(tmp_path / "first.log")):
- os.write(1, b"first\n")
- first_entered.set()
- release_first.wait(timeout=2)
-
- def capture_second():
- first_entered.wait(timeout=2)
- second_attempted.set()
- with capture_stdio_to_file(str(tmp_path / "second.log")):
- second_entered.set()
- os.write(1, b"second\n")
-
- first = threading.Thread(target=capture_first)
- second = threading.Thread(target=capture_second)
- first.start()
- second.start()
- try:
- assert first_entered.wait(timeout=2)
- assert second_attempted.wait(timeout=2)
- assert not second_entered.wait(timeout=0.05)
- finally:
- release_first.set()
- first.join(timeout=2)
- second.join(timeout=2)
-
- assert not first.is_alive()
- assert not second.is_alive()
- assert second_entered.is_set()
- assert (tmp_path / "first.log").read_text() == "first\n"
- assert (tmp_path / "second.log").read_text() == "second\n"
-
-
-def test_rpc_redirect_does_not_restore_step_capture_after_capture_exits(tmp_path):
- original_fds = (os.dup(1), os.dup(2))
- stdout_target = tmp_path / "stdout.txt"
- stderr_target = tmp_path / "stderr.txt"
- step_log = tmp_path / "step.log"
- capture_entered = threading.Event()
- release_capture = threading.Event()
- capture_exited = threading.Event()
-
- def capture_step():
- with capture_stdio_to_file(str(step_log)):
- capture_entered.set()
- release_capture.wait(timeout=2)
- capture_exited.set()
-
- def dispatch_rpc():
- capture_entered.wait(timeout=2)
- with redirect_stdout_to_stderr():
- release_capture.set()
- capture_exited.wait(timeout=2)
-
- with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file:
- os.dup2(stdout_file.fileno(), 1)
- os.dup2(stderr_file.fileno(), 2)
- first = threading.Thread(target=capture_step)
- second = threading.Thread(target=dispatch_rpc)
- try:
- first.start()
- second.start()
- first.join(timeout=2)
- second.join(timeout=2)
- assert not first.is_alive()
- assert not second.is_alive()
- os.write(1, b"after capture\n")
- finally:
- os.dup2(original_fds[0], 1)
- os.dup2(original_fds[1], 2)
- os.close(original_fds[0])
- os.close(original_fds[1])
- sys.stdout = sys.__stdout__
- sys.stderr = sys.__stderr__
-
- assert stdout_target.read_text() == "after capture\n"
- assert "after capture" not in step_log.read_text()
diff --git a/test/runtime/test_layout_edit.py b/test/runtime/test_layout_edit.py
deleted file mode 100644
index 4e43d54d..00000000
--- a/test/runtime/test_layout_edit.py
+++ /dev/null
@@ -1,608 +0,0 @@
-import json
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-
-from chipcompiler.runtime.requests import (
- FloorplanEditInspectRequest,
- FloorplanEditRunAutoRequest,
- FloorplanEditValidateRequest,
- LayoutEditApplyRequest,
- LayoutEditBeginRequest,
- LayoutEditDiscardRequest,
- LayoutEditSaveRequest,
-)
-from chipcompiler.runtime.sessions import WorkspaceSessionRegistry
-from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi
-
-
-class FakeLayoutModule:
- def __init__(self):
- self.initialize_calls = 0
- self.reset_calls = 0
- self.place_calls = []
- self.sync_calls = []
- self.export_calls = []
- self.session_snapshot_calls = []
- self.editor_calls = []
- self.validation_result = {"ok": True, "diagnostics": []}
- self.export_intent = {"ok": True}
-
- def initialize_geometry_session(self):
- self.initialize_calls += 1
- return True
-
- def reset_geometry_session(self):
- self.reset_calls += 1
- return True
-
- def place_instance(self, **kwargs):
- self.place_calls.append(kwargs)
- return True
-
- def sync_instance_geometry(self, inst_name):
- self.sync_calls.append(inst_name)
- return {
- "ok": True,
- "snapshotRequired": False,
- "updatedShapeCount": 1,
- "insertedShapeCount": 0,
- "deletedShapeCount": 0,
- "missingShapeCount": 0,
- "events": [{"shapeId": 11, "op": "update"}],
- }
-
- def def_save(self, def_path):
- self.export_calls.append("def")
- Path(def_path).write_text("new def", encoding="utf-8")
-
- def save_data(self, path):
- self.export_calls.append("db")
- db_path = Path(path)
- db_path.mkdir(parents=True)
- (db_path / "metadata.idb").write_text("new db", encoding="utf-8")
- return True
-
- def gds_save(self, output_path):
- self.export_calls.append("gds")
- Path(output_path).write_text("new gds", encoding="utf-8")
-
- def geometry_snapshot_save(self, output_dir):
- self.export_calls.append("geometry")
- geometry_dir = Path(output_dir)
- geometry_dir.mkdir(parents=True)
- (geometry_dir / "geometry.manifest").write_text("new geometry", encoding="utf-8")
- return True
-
- def geometry_session_snapshot_save(self, output_dir):
- self.session_snapshot_calls.append(Path(output_dir))
- geometry_dir = Path(output_dir)
- geometry_dir.mkdir(parents=True)
- (geometry_dir / "geometry.manifest").write_text("session geometry", encoding="utf-8")
- return True
-
- def floorplan_editor_apply(self, request):
- self.editor_calls.append(request)
- return {
- "accepted": True,
- "changed": True,
- "affectedRefs": [{"kind": "blockage", "id": "blockage-1"}],
- "geometryDelta": {
- "ok": True,
- "snapshotRequired": True,
- "updatedShapeCount": 0,
- "insertedShapeCount": 1,
- "deletedShapeCount": 0,
- "missingShapeCount": 0,
- "events": [{"shapeId": 31, "op": "insert"}],
- },
- "modelPatch": {
- "floorplanPlan": {"placement_blockages": [{"id": "blockage-1"}]},
- "pdnPlan": {"manual_segments": [{"id": "segment-1"}]},
- "configPatch": {"editor": {"enabled": True}},
- "parametersPatch": {"Floorplan": {"edited": True}},
- },
- "diagnostics": [{"severity": "warning", "message": "preview"}],
- }
-
- def floorplan_editor_validate(self, scope):
- assert scope
- return self.validation_result
-
- def floorplan_editor_export_intent(self):
- return self.export_intent
-
- def floorplan_editor_inspect(self):
- return {"ownerCount": 3}
-
- def verilog_save(self, output_verilog):
- self.export_calls.append("verilog")
- Path(output_verilog).write_text("module gcd; endmodule\n", encoding="utf-8")
-
-
-class FakeEngineDb:
- def __init__(self, module):
- self.ecc_module = module
- self.initialized = False
- self.created_for = []
- self.close_calls = 0
-
- @property
- def engine(self):
- return self.ecc_module
-
- def has_init(self):
- return self.initialized
-
- def create_db_engine(self, step):
- self.created_for.append(step)
- self.initialized = True
- return True
-
- def close(self):
- self.close_calls += 1
- self.initialized = False
-
-
-class FakeFlow:
- def __init__(self, workspace, step, module):
- self.workspace = workspace
- self.workspace_step = step
- self.engine_db = FakeEngineDb(module)
-
- def get_workspace_step(self, name):
- return self.workspace_step if name == self.workspace_step.name else None
-
-
-def _make_layout_workspace(tmp_path, *, with_db=False, with_editor_workspace=False):
- workspace_dir = tmp_path / "workspace"
- output_dir = workspace_dir / "Floorplan_ecc" / "output"
- output_dir.mkdir(parents=True)
- output_def = output_dir / "gcd_Floorplan.def.gz"
- output_def.write_text("old def", encoding="utf-8")
- output_db = output_dir / "gcd_Floorplan_db"
- if with_db:
- output_db.mkdir()
- (output_db / "metadata.idb").write_text("old db", encoding="utf-8")
- step = SimpleNamespace(
- name="Floorplan",
- input={"def": None, "verilog": None, "db": None},
- output={
- "def": output_def,
- "db": output_db,
- "gds": output_dir / "gcd_Floorplan.gds",
- "geometry": output_dir / "geometry",
- "geometry_manifest": output_dir / "geometry" / "geometry.manifest",
- "verilog": output_dir / "gcd_Floorplan.v",
- },
- )
- workspace = SimpleNamespace(directory=workspace_dir)
- if with_editor_workspace:
- config_path = workspace_dir / "config" / "floorplan_ecc.json"
- config_path.parent.mkdir()
- config_path.write_text('{"legacy": true}\n', encoding="utf-8")
- parameters_path = workspace_dir / "parameters.json"
- parameters_path.write_text('{"floorplan": {"edited": false}}\n', encoding="utf-8")
- flow_path = workspace_dir / "flow.json"
- flow_data = {
- "steps": [
- {"name": "Floorplan", "state": "Success", "runtime": "1s"},
- {"name": "place", "state": "Success", "runtime": "2s"},
- {"name": "route", "state": "Success", "runtime": "3s"},
- ]
- }
- flow_path.write_text(json.dumps(flow_data), encoding="utf-8")
- workspace.config = {"Floorplan": config_path}
- workspace.parameters = SimpleNamespace(
- path=parameters_path,
- data={"floorplan": {"edited": False}},
- )
- workspace.flow = SimpleNamespace(path=flow_path, data=flow_data)
- return workspace, step
-
-
-def _open_api(monkeypatch, tmp_path, *, with_db=False, with_editor_workspace=False):
- workspace, step = _make_layout_workspace(
- tmp_path,
- with_db=with_db,
- with_editor_workspace=with_editor_workspace,
- )
- module = FakeLayoutModule()
- flow_calls = []
-
- def build_flow(_workspace):
- flow = FakeFlow(_workspace, step, module)
- flow_calls.append(flow)
- return flow
-
- monkeypatch.setattr("chipcompiler.runtime.workspace_api.build_flow_for_workspace", build_flow)
- registry = WorkspaceSessionRegistry()
- session = registry.open_session(workspace.directory, workspace=workspace)
- api = WorkspaceRuntimeApi(sessions=registry, persistent_db_enabled=True)
- return api, session, step, module, flow_calls
-
-
-def _begin(api, workspace_id, **kwargs):
- return api.layout_edit_begin(
- LayoutEditBeginRequest(workspace_id=workspace_id, step="Floorplan", **kwargs)
- )
-
-
-def _apply(api, edit_session_id, *, revision=0, command_id="move-1"):
- return api.layout_edit_apply(
- LayoutEditApplyRequest(
- edit_session_id=edit_session_id,
- command_id=command_id,
- base_revision=revision,
- operation={
- "kind": "place_instance",
- "instName": "u_sram_0",
- "llx": 1200,
- "lly": 3400,
- "orient": "N",
- "cellmaster": "",
- "source": "",
- "placementStatus": "preserve",
- "createIfMissing": False,
- },
- )
- )
-
-
-def test_layout_edit_begin_loads_output_def_when_output_db_is_absent(monkeypatch, tmp_path):
- api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path)
-
- result = _begin(api, session.workspace_id)
-
- assert result["source"] == "def"
- assert result["dirty"] is False
- assert Path(result["geometryManifestPath"]).is_file()
- assert module.initialize_calls == 1
- loaded_step = flow_calls[0].engine_db.created_for[0]
- assert loaded_step is not step
- assert loaded_step.input["db"] is None
- assert loaded_step.input["def"] == step.output["def"]
-
-
-def test_layout_edit_begin_prefers_selected_step_output_db(monkeypatch, tmp_path):
- api, session, step, _module, flow_calls = _open_api(monkeypatch, tmp_path, with_db=True)
-
- result = _begin(api, session.workspace_id)
-
- assert result["source"] == "db"
- loaded_step = flow_calls[0].engine_db.created_for[0]
- assert loaded_step.input["db"] == step.output["db"]
-
-
-def test_layout_edit_begin_rejects_another_workspace_until_active_session_is_discarded(
- monkeypatch,
- tmp_path,
-):
- api, first_session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- second_workspace = SimpleNamespace(directory=tmp_path / "second-workspace")
- second_session = api.sessions.open_session(
- second_workspace.directory,
- workspace=second_workspace,
- )
-
- first = _begin(api, first_session.workspace_id)
-
- with pytest.raises(RuntimeApiError) as exc_info:
- _begin(api, second_session.workspace_id)
-
- assert exc_info.value.code == "layout_edit_active"
- assert exc_info.value.data == {
- "editSessionId": first["editSessionId"],
- "workspaceId": first_session.workspace_id,
- }
- assert module.initialize_calls == 1
-
- api.layout_edit_discard(LayoutEditDiscardRequest(first["editSessionId"]))
- second = _begin(api, second_session.workspace_id)
-
- assert second["workspaceId"] == second_session.workspace_id
- assert module.initialize_calls == 2
-
-
-def test_layout_edit_apply_calls_place_instance_without_persisting_artifacts(monkeypatch, tmp_path):
- api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- original_def = step.output["def"].read_text(encoding="utf-8")
-
- result = _apply(api, begin["editSessionId"])
-
- assert result["revision"] == 1
- assert result["dirty"] is True
- assert result["geometryDelta"]["events"] == [{"shapeId": 11, "op": "update"}]
- assert module.place_calls == [
- {
- "inst_name": "u_sram_0",
- "llx": 1200,
- "lly": 3400,
- "orient": "N",
- "cellmaster": "",
- "source": "",
- "placement_status": "preserve",
- "create_if_missing": False,
- }
- ]
- assert module.sync_calls == ["u_sram_0"]
- assert module.export_calls == []
- assert len(module.session_snapshot_calls) == 2
- assert Path(result["geometryManifestPath"]).is_file()
- assert step.output["def"].read_text(encoding="utf-8") == original_def
- assert not step.output["db"].exists()
- assert not step.output["gds"].exists()
- assert not step.output["geometry"].exists()
-
-
-def test_layout_edit_save_publishes_staged_outputs_only_after_explicit_save(monkeypatch, tmp_path):
- api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- apply = _apply(api, begin["editSessionId"])
-
- saved = api.layout_edit_save(
- LayoutEditSaveRequest(
- edit_session_id=begin["editSessionId"],
- expected_revision=apply["revision"],
- )
- )
-
- assert saved["saved"] is True
- assert saved["dirty"] is False
- assert saved["artifacts"] == {
- "defPath": str(step.output["def"]),
- "dbPath": str(step.output["db"]),
- "gdsPath": str(step.output["gds"]),
- "geometryManifestPath": str(step.output["geometry_manifest"]),
- }
- assert module.export_calls == ["def", "db", "gds", "geometry"]
- assert step.output["def"].read_text(encoding="utf-8") == "new def"
- assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "new db"
- assert step.output["gds"].read_text(encoding="utf-8") == "new gds"
- geometry_manifest = step.output["geometry"] / "geometry.manifest"
- assert geometry_manifest.read_text(encoding="utf-8") == "new geometry"
- assert not list(step.output["def"].parent.glob(".layout-edit-*"))
-
-
-def test_layout_edit_discard_drops_in_memory_db_without_publishing(monkeypatch, tmp_path):
- api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- _apply(api, begin["editSessionId"])
- geometry_root = Path(begin["geometryManifestPath"]).parent.parent
-
- result = api.layout_edit_discard(LayoutEditDiscardRequest(begin["editSessionId"]))
-
- assert result == {
- "editSessionId": begin["editSessionId"],
- "discarded": True,
- "dirty": True,
- }
- assert flow_calls[0].engine_db.close_calls == 1
- assert module.reset_calls == 1
- assert not geometry_root.exists()
- assert step.output["def"].read_text(encoding="utf-8") == "old def"
- assert module.export_calls == []
- with pytest.raises(RuntimeApiError, match="layout edit session not found"):
- _apply(api, begin["editSessionId"])
-
-
-def test_layout_edit_save_rejects_external_source_change(monkeypatch, tmp_path):
- api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- apply = _apply(api, begin["editSessionId"])
- step.output["def"].write_text("external change", encoding="utf-8")
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.layout_edit_save(
- LayoutEditSaveRequest(
- edit_session_id=begin["editSessionId"],
- expected_revision=apply["revision"],
- )
- )
-
- assert exc_info.value.code == "source_changed"
- assert module.export_calls == []
- assert step.output["def"].read_text(encoding="utf-8") == "external change"
-
-
-def test_layout_edit_save_rolls_back_when_publish_fails(monkeypatch, tmp_path):
- api, session, step, _module, _flow_calls = _open_api(monkeypatch, tmp_path, with_db=True)
- step.output["gds"].write_text("old gds", encoding="utf-8")
- step.output["geometry"].mkdir()
- (step.output["geometry"] / "geometry.manifest").write_text(
- "old geometry",
- encoding="utf-8",
- )
- begin = _begin(api, session.workspace_id)
- apply = _apply(api, begin["editSessionId"])
- real_replace = __import__("os").replace
-
- def fail_gds_publish(source, destination):
- if Path(source).name == step.output["gds"].name and Path(destination) == step.output["gds"]:
- raise OSError("simulated publish failure")
- real_replace(source, destination)
-
- monkeypatch.setattr("chipcompiler.runtime.workspace_api.os.replace", fail_gds_publish)
-
- with pytest.raises(RuntimeApiError, match="simulated publish failure"):
- api.layout_edit_save(
- LayoutEditSaveRequest(
- edit_session_id=begin["editSessionId"],
- expected_revision=apply["revision"],
- )
- )
-
- assert step.output["def"].read_text(encoding="utf-8") == "old def"
- assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "old db"
- assert step.output["gds"].read_text(encoding="utf-8") == "old gds"
- geometry_manifest = step.output["geometry"] / "geometry.manifest"
- assert geometry_manifest.read_text(encoding="utf-8") == "old geometry"
-
-
-def test_layout_edit_apply_rejects_stale_revision_before_mutation(monkeypatch, tmp_path):
- api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- _apply(api, begin["editSessionId"])
-
- with pytest.raises(RuntimeApiError) as exc_info:
- _apply(api, begin["editSessionId"], revision=0, command_id="move-2")
-
- assert exc_info.value.code == "version_conflict"
- assert len(module.place_calls) == 1
-
-
-def test_layout_edit_apply_replays_completed_command_before_revision_validation(
- monkeypatch,
- tmp_path,
-):
- api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
- first = _apply(api, begin["editSessionId"])
-
- replay = _apply(api, begin["editSessionId"], revision=0)
-
- assert replay == first
- assert len(module.place_calls) == 1
-
-
-def test_layout_edit_apply_allows_preserve_orientation_for_existing_instance(
- monkeypatch,
- tmp_path,
-):
- api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
-
- result = api.layout_edit_apply(
- LayoutEditApplyRequest(
- edit_session_id=begin["editSessionId"],
- command_id="move-with-preserved-orient",
- base_revision=0,
- operation={
- "kind": "place_instance",
- "instName": "u_sram_0",
- "llx": 1200,
- "lly": 3400,
- "orient": "",
- "placementStatus": "preserve",
- "createIfMissing": False,
- },
- )
- )
-
- assert result["revision"] == 1
- assert module.place_calls[0]["orient"] == ""
-
-
-def test_floorplan_editor_apply_inspect_validate_and_save_publish_editor_artifacts(
- monkeypatch,
- tmp_path,
-):
- api, session, step, module, _flow_calls = _open_api(
- monkeypatch,
- tmp_path,
- with_editor_workspace=True,
- )
- begin = _begin(api, session.workspace_id)
-
- applied = api.layout_edit_apply(
- LayoutEditApplyRequest(
- edit_session_id=begin["editSessionId"],
- command_id="blockage-1",
- base_revision=0,
- operation={"kind": "upsert_blockage", "id": "blockage-1"},
- )
- )
-
- assert applied["revision"] == 1
- assert applied["affectedRefs"] == [{"kind": "blockage", "id": "blockage-1"}]
- assert applied["modelPatch"]["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}]
- assert module.editor_calls == [{"kind": "upsert_blockage", "id": "blockage-1"}]
- config_path = session.workspace.config["Floorplan"]
- assert config_path.read_text(encoding="utf-8") == '{"legacy": true}\n'
-
- inspected = api.floorplan_edit_inspect(
- FloorplanEditInspectRequest(edit_session_id=begin["editSessionId"])
- )
- assert inspected["state"] == {"ownerCount": 3}
- assert inspected["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}]
-
- validated = api.floorplan_edit_validate(
- FloorplanEditValidateRequest(edit_session_id=begin["editSessionId"], scope="pdn")
- )
- assert validated["valid"] is True
-
- module.export_intent = {
- "ok": True,
- "floorplanPlan": {"outline": {"die": [0, 0, 100, 100]}},
- "pdnPlan": {"manual_vias": [{"id": "via-1"}]},
- "parametersPatch": {"PDN": {"edited": True}},
- "requiresVerilog": True,
- }
- saved = api.layout_edit_save(
- LayoutEditSaveRequest(
- edit_session_id=begin["editSessionId"],
- expected_revision=applied["revision"],
- )
- )
-
- assert saved["saved"] is True
- assert saved["artifacts"]["configPath"] == str(config_path)
- assert saved["artifacts"]["parametersPath"] == str(session.workspace.parameters.path)
- assert saved["artifacts"]["verilogPath"] == str(step.output["verilog"])
- assert saved["artifacts"]["flowPath"] == str(session.workspace.flow.path)
- assert module.export_calls == ["def", "db", "gds", "geometry", "verilog"]
- saved_config = json.loads(config_path.read_text(encoding="utf-8"))
- assert saved_config["FloorplanPlan"]["outline"] == {"die": [0, 0, 100, 100]}
- assert saved_config["PdnPlan"]["manual_vias"] == [{"id": "via-1"}]
- assert saved_config["editor"] == {"enabled": True}
- saved_parameters = json.loads(session.workspace.parameters.path.read_text(encoding="utf-8"))
- assert saved_parameters["floorplan"]["edited"] is True
- assert saved_parameters["pdn"] == {"edited": True}
- assert step.output["verilog"].is_file()
- stale_steps = session.workspace.flow.data["steps"][1:]
- assert [item["state"] for item in stale_steps] == ["Unstart", "Unstart"]
- assert [item["runtime"] for item in stale_steps] == ["", ""]
-
-
-def test_floorplan_editor_run_auto_is_idempotent_and_save_rejects_invalid_result(
- monkeypatch,
- tmp_path,
-):
- api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path)
- begin = _begin(api, session.workspace_id)
-
- first = api.floorplan_edit_run_auto(
- FloorplanEditRunAutoRequest(
- edit_session_id=begin["editSessionId"],
- command_id="auto-1",
- base_revision=0,
- request={"mode": "macro"},
- )
- )
- replay = api.floorplan_edit_run_auto(
- FloorplanEditRunAutoRequest(
- edit_session_id=begin["editSessionId"],
- command_id="auto-1",
- base_revision=0,
- request={"mode": "macro"},
- )
- )
-
- assert replay == first
- assert module.editor_calls == [{"kind": "run_auto", "request": {"mode": "macro"}}]
- module.validation_result = {
- "ok": False,
- "diagnostics": [{"severity": "error", "message": "bad outline"}],
- }
- with pytest.raises(RuntimeApiError) as exc_info:
- api.layout_edit_save(
- LayoutEditSaveRequest(
- edit_session_id=begin["editSessionId"],
- expected_revision=first["revision"],
- )
- )
-
- assert exc_info.value.code == "floorplan_validation_failed"
- assert module.export_calls == []
diff --git a/test/runtime/test_methods.py b/test/runtime/test_methods.py
deleted file mode 100644
index b3fb0d12..00000000
--- a/test/runtime/test_methods.py
+++ /dev/null
@@ -1,154 +0,0 @@
-from dataclasses import is_dataclass
-
-import chipcompiler.runtime.requests as requests
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- FloorplanEditInspectRequest,
- FloorplanEditRunAutoRequest,
- FloorplanEditValidateRequest,
- WorkspaceOpenRequest,
-)
-from chipcompiler.runtime.server import BASE_CAPABILITIES, RuntimeServer
-
-
-def test_runtime_method_registry_contains_current_methods_once():
- from chipcompiler.runtime.methods import RUNTIME_METHODS, runtime_method_names
-
- expected_methods = (
- "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",
- )
-
- assert runtime_method_names() == expected_methods
- assert len(runtime_method_names()) == len(set(runtime_method_names()))
- assert len(RUNTIME_METHODS) == len(expected_methods)
-
-
-def test_persistent_db_method_registry_is_separate_and_opt_in():
- from chipcompiler.runtime.methods import (
- PERSISTENT_DB_METHODS,
- persistent_db_method_names,
- runtime_method_names,
- )
-
- expected_methods = (
- "db.ensure",
- "db.release",
- "layout.edit.begin",
- "layout.edit.apply",
- "layout.edit.save",
- "layout.edit.discard",
- "floorplan.edit.inspect",
- "floorplan.edit.run_auto",
- "floorplan.edit.validate",
- )
-
- assert persistent_db_method_names() == expected_methods
- enabled_methods = runtime_method_names(persistent_db_enabled=True)
- assert enabled_methods[-len(expected_methods) :] == expected_methods
- assert "db.ensure" not in runtime_method_names()
- assert len(PERSISTENT_DB_METHODS) == len(expected_methods)
-
-
-def test_runtime_method_registry_entries_are_typed():
- from chipcompiler.runtime.methods import runtime_methods
-
- for spec in runtime_methods(persistent_db_enabled=True):
- assert spec.method_name
- assert isinstance(spec.request_model, type)
- assert is_dataclass(spec.request_model)
- assert spec.handler_name
-
-
-def test_runtime_method_lookup_returns_spec():
- from chipcompiler.runtime.methods import runtime_method_by_name
-
- spec = runtime_method_by_name("workspace.open")
-
- assert spec is not None
- assert spec.request_model is WorkspaceOpenRequest
- assert spec.handler_name == "open_workspace"
- export_spec = runtime_method_by_name("workspace.export_signoff")
- assert export_spec is not None
- assert export_spec.request_model is requests.WorkspaceExportSignoffRequest
- assert export_spec.handler_name == "export_signoff"
- inspect_spec = runtime_method_by_name("workspace.inspect_signoff")
- assert inspect_spec is not None
- assert inspect_spec.request_model is requests.WorkspaceInspectSignoffRequest
- assert inspect_spec.handler_name == "inspect_signoff"
- assert runtime_method_by_name("db.ensure") is None
-
-
-def test_persistent_db_method_lookup_requires_enabled_capability():
- from chipcompiler.runtime.methods import runtime_method_by_name
-
- ensure_spec = runtime_method_by_name("db.ensure", persistent_db_enabled=True)
- release_spec = runtime_method_by_name("db.release", persistent_db_enabled=True)
-
- assert ensure_spec is not None
- assert ensure_spec.request_model is DbEnsureRequest
- assert ensure_spec.handler_name == "db_ensure"
- assert release_spec is not None
- assert release_spec.request_model is DbReleaseRequest
- assert release_spec.handler_name == "db_release"
- inspect_spec = runtime_method_by_name("floorplan.edit.inspect", persistent_db_enabled=True)
- assert inspect_spec is not None
- assert inspect_spec.request_model is FloorplanEditInspectRequest
- auto_spec = runtime_method_by_name("floorplan.edit.run_auto", persistent_db_enabled=True)
- assert auto_spec is not None
- assert auto_spec.request_model is FloorplanEditRunAutoRequest
- validate_spec = runtime_method_by_name("floorplan.edit.validate", persistent_db_enabled=True)
- assert validate_spec is not None
- assert validate_spec.request_model is FloorplanEditValidateRequest
-
-
-def test_default_server_capabilities_are_generated_from_runtime_registry():
- from chipcompiler.runtime.methods import runtime_method_names
-
- server = RuntimeServer()
-
- assert server.capabilities == (*BASE_CAPABILITIES, *runtime_method_names())
-
-
-def test_persistent_db_server_capabilities_include_db_methods():
- from chipcompiler.runtime.methods import runtime_method_names
-
- server = RuntimeServer(persistent_db_enabled=True)
-
- assert server.capabilities == (
- *BASE_CAPABILITIES,
- *runtime_method_names(persistent_db_enabled=True),
- )
- assert "db.ensure" in server.capabilities
- assert "db.release" in server.capabilities
- assert "layout.edit.begin" in server.capabilities
- assert "layout.edit.save" in server.capabilities
- assert "floorplan.edit.inspect" in server.capabilities
-
-
-def test_requests_module_does_not_own_runtime_method_table():
- assert not hasattr(requests, "REQUEST_MODELS")
-
-
-def test_server_module_does_not_own_runtime_method_table():
- import chipcompiler.runtime.server as server
-
- assert not hasattr(server, "RUNTIME_METHODS")
diff --git a/test/runtime/test_operations.py b/test/runtime/test_operations.py
deleted file mode 100644
index 70c9e77e..00000000
--- a/test/runtime/test_operations.py
+++ /dev/null
@@ -1,518 +0,0 @@
-import threading
-from types import SimpleNamespace
-
-from chipcompiler.data import StateEnum
-from chipcompiler.runtime import operations
-from chipcompiler.runtime.operations import RuntimeOperationManager
-
-
-def test_successful_step_waits_for_matching_render_ack_before_completing():
- events = []
- entered_render_gate = threading.Event()
- completed = threading.Event()
- manager = RuntimeOperationManager(events.append)
- step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file=""))
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Success)
- entered_render_gate.set()
- assert observer.wait_for_step_rendered(step, StateEnum.Success)
- completed.set()
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-1",
- runner=runner,
- )
-
- assert started["state"] in {"queued", "running", "waiting_for_gui_sync"}
- assert entered_render_gate.wait(timeout=1)
- assert not completed.wait(timeout=0.05)
- step_completed = next(event for event in events if event["type"] == "step.completed")
- assert step_completed["payload"]["stepCommitId"]
- assert step_completed["payload"]["workspaceRevision"] == 1
- assert not manager.acknowledge_step_rendered(
- started["operationId"],
- step_completed["eventId"],
- "wrong-step-commit",
- step_completed["payload"]["workspaceRevision"],
- )["accepted"]
-
- assert manager.acknowledge_step_rendered(
- started["operationId"],
- step_completed["eventId"],
- step_completed["payload"]["stepCommitId"],
- step_completed["payload"]["workspaceRevision"],
- ) == {
- "accepted": True,
- "duplicate": False,
- "operationId": started["operationId"],
- "eventId": step_completed["eventId"],
- }
- assert completed.wait(timeout=1)
- assert manager.operation_status(started["operationId"])["state"] == "succeeded"
- assert events[-1]["type"] == "operation.completed"
-
-
-def test_subflow_stage_is_emitted_for_the_active_workspace_step():
- events = []
- released = threading.Event()
- manager = RuntimeOperationManager(events.append)
- step = SimpleNamespace(name="Floorplan", tool="ecc", log=SimpleNamespace(file=""))
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_subflow_stage(
- step,
- {
- "name": "init floorplan",
- "state": "Ongoing",
- "runtime": "0:0:1",
- "peak memory (mb)": 12.5,
- },
- )
- assert released.wait(timeout=1)
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=True,
- step="Floorplan",
- idempotency_key="subflow-stage",
- runner=runner,
- )
-
- event = _wait_for_event(events, "subflow.stage")
- assert event["operationId"] == started["operationId"]
- assert event["payload"] == {
- "peakMemory": 12.5,
- "runtime": "0:0:1",
- "state": "Ongoing",
- "step": "Floorplan",
- "subflowStep": "init floorplan",
- "tool": "ecc",
- }
- released.set()
- assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded"
-
-
-def test_render_ack_replays_one_commit_then_pauses_before_a_bounded_timeout(monkeypatch):
- monkeypatch.setattr(operations, "_RENDER_ACK_RETRY_SECONDS", 0.01)
- monkeypatch.setattr(operations, "_RENDER_ACK_PAUSE_SECONDS", 0.02)
- monkeypatch.setattr(operations, "_RENDER_ACK_ABORT_SECONDS", 0.5)
- events = []
- entered_render_gate = threading.Event()
- manager = RuntimeOperationManager(events.append)
- step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file=""))
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Success)
- entered_render_gate.set()
- assert observer.wait_for_step_rendered(step, StateEnum.Success)
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-replay",
- runner=runner,
- )
- assert entered_render_gate.wait(timeout=1)
- step_completed = _wait_for_event(events, "step.completed")
- paused = _wait_for_event(events, "operation.gui_sync_paused")
- replays = [
- event
- for event in events
- if event["type"] == "step.completed" and event["payload"].get("replayed")
- ]
- assert paused["payload"]["stepCommitId"] == step_completed["payload"]["stepCommitId"]
- assert replays
- assert all(event["eventId"] == step_completed["eventId"] for event in replays)
- assert (
- manager.operation_status(started["operationId"])["renderSyncState"]
- == "paused_for_gui_recovery"
- )
-
- assert manager.acknowledge_step_rendered(
- started["operationId"],
- step_completed["eventId"],
- step_completed["payload"]["stepCommitId"],
- step_completed["payload"]["workspaceRevision"],
- )["accepted"]
- assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded"
-
-
-def test_ack_and_start_requests_are_idempotent():
- events = []
- release = threading.Event()
- manager = RuntimeOperationManager(events.append)
-
- def runner(_observer):
- assert release.wait(timeout=1)
- return {"rerun": False}
-
- first = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-1",
- runner=runner,
- )
- duplicate = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-1",
- runner=runner,
- )
-
- assert duplicate["operationId"] == first["operationId"]
- assert duplicate["deduplicated"] is True
- assert manager.acknowledge_step_rendered(first["operationId"], "workspace-1:missing") == {
- "accepted": False,
- "duplicate": False,
- "operationId": first["operationId"],
- "eventId": "workspace-1:missing",
- }
- release.set()
- assert _wait_for_terminal(manager, first["operationId"])["state"] == "succeeded"
-
-
-def test_event_identity_is_unique_across_sidecar_operation_managers():
- first_events = []
- second_events = []
- first = RuntimeOperationManager(first_events.append)
- second = RuntimeOperationManager(second_events.append)
-
- first.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="first",
- runner=lambda _observer: {"rerun": False},
- )
- second.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=True,
- step="",
- idempotency_key="second",
- runner=lambda _observer: {"rerun": True},
- )
-
- first_queued = _wait_for_event(first_events, "operation.queued")
- second_queued = _wait_for_event(second_events, "operation.queued")
- assert first_queued["sequence"] == second_queued["sequence"] == 1
- assert first_queued["eventId"] != second_queued["eventId"]
- assert first_queued["runtimeInstanceId"] != second_queued["runtimeInstanceId"]
- assert first_queued["operationId"] != second_queued["operationId"]
- assert first_queued["runSessionId"] != second_queued["runSessionId"]
-
-
-def test_rerun_prepared_event_carries_the_affected_steps_once():
- events = []
- manager = RuntimeOperationManager(events.append)
-
- def runner(observer):
- observer.on_rerun_prepared(
- scope="step",
- target_step="Floorplan",
- affected_steps=["Floorplan", "route"],
- )
- return {"rerun": True}
-
- first = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=True,
- step="Floorplan",
- idempotency_key="rerun-prepared",
- runner=runner,
- )
- duplicate = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=True,
- step="Floorplan",
- idempotency_key="rerun-prepared",
- runner=runner,
- )
-
- assert duplicate["operationId"] == first["operationId"]
- prepared = _wait_for_event(events, "operation.rerun_prepared")
- assert prepared["payload"] == {
- "affectedSteps": ["Floorplan", "route"],
- "scope": "step",
- "targetStep": "Floorplan",
- }
- assert len([event for event in events if event["type"] == "operation.rerun_prepared"]) == 1
-
-
-def test_render_ack_timeout_degrades_and_allows_the_flow_to_continue(monkeypatch):
- monkeypatch.setattr(operations, "_RENDER_ACK_RETRY_SECONDS", 0.01)
- monkeypatch.setattr(operations, "_RENDER_ACK_PAUSE_SECONDS", 0.02)
- monkeypatch.setattr(operations, "_RENDER_ACK_ABORT_SECONDS", 0.04)
- events = []
- manager = RuntimeOperationManager(events.append)
- step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file=""))
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Success)
- assert observer.wait_for_step_rendered(step, StateEnum.Success)
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Success)
- assert observer.wait_for_step_rendered(step, StateEnum.Success)
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-degraded-sync",
- runner=runner,
- )
-
- degraded = _wait_for_event(events, "operation.gui_sync_degraded")
- assert degraded["payload"]["stepCommitId"]
- assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded"
- completed_events = [
- event
- for event in events
- if event["type"] == "step.completed" and not event["payload"].get("replayed")
- ]
- assert len(completed_events) == 2
- assert completed_events[1]["payload"]["stepCommitId"]
- assert (
- manager.operation_status(started["operationId"])["renderSyncState"] == "gui_sync_degraded"
- )
- assert not any(event["type"] == "operation.failed" for event in events)
-
-
-def test_active_operation_reports_a_shutdown_barrier_and_safe_boundary():
- release = threading.Event()
- manager = RuntimeOperationManager()
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-1",
- runner=lambda _observer: (release.wait(timeout=1), {"rerun": False})[1],
- )
-
- barrier = manager.shutdown_barrier()
- status = manager.operation_status(started["operationId"])
-
- assert barrier is not None
- assert barrier["operationId"] == started["operationId"]
- assert barrier["interruptibility"] == "deferred"
- assert status["shutdownBarrier"] is True
- assert status["safeToStop"] is False
- release.set()
-
-
-def test_cancel_at_render_ack_boundary_releases_the_waiting_flow():
- entered_render_gate = threading.Event()
- manager = RuntimeOperationManager()
- step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file=""))
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Success)
- entered_render_gate.set()
- if not observer.wait_for_step_rendered(step, StateEnum.Success):
- raise RuntimeError("operation cancelled at a render boundary")
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-cancel-at-gate",
- runner=runner,
- )
- assert entered_render_gate.wait(timeout=1)
-
- assert manager.request_cancel(started["operationId"])["accepted"] is True
- assert _wait_for_terminal(manager, started["operationId"])["state"] == "cancelled"
-
-
-def test_step_log_events_stream_only_new_log_bytes_and_keep_final_tail(tmp_path):
- events = []
- step_started = threading.Event()
- complete_step = threading.Event()
- manager = RuntimeOperationManager(events.append)
- log_file = tmp_path / "Synthesis.log"
- log_file.write_text("previous run\n", encoding="utf-8")
- step = SimpleNamespace(
- name="Synthesis",
- tool="yosys",
- log=SimpleNamespace(file=str(log_file)),
- )
-
- def runner(observer):
- observer.on_step_started(step)
- step_started.set()
- assert complete_step.wait(timeout=2)
- observer.on_step_completed(step, StateEnum.Success)
- assert observer.wait_for_step_rendered(step, StateEnum.Success)
- return {"rerun": False}
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="flow",
- origin="gui",
- rerun=False,
- step="",
- idempotency_key="request-log-stream",
- runner=runner,
- )
- assert step_started.wait(timeout=1)
- with log_file.open("a", encoding="utf-8") as handle:
- handle.write("live line one\nlive line two\n")
-
- step_log = _wait_for_event(events, "step.log")
- assert step_log["payload"]["chunk"] == "live line one\nlive line two\n"
- assert step_log["payload"]["cursor"] == log_file.stat().st_size
-
- complete_step.set()
- step_complete = _wait_for_event(events, "step.completed")
- assert step_complete["payload"]["finalLog"] == ("previous run\nlive line one\nlive line two\n")
- assert manager.acknowledge_step_rendered(started["operationId"], step_complete["eventId"])[
- "accepted"
- ]
- assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded"
-
-
-def test_step_error_survives_generic_runner_error_and_releases_workspace(tmp_path):
- events = []
- manager = RuntimeOperationManager(events.append)
- log_file = tmp_path / "place.log"
- log_file.write_text("traceback\n", encoding="utf-8")
- step = SimpleNamespace(
- name="place",
- tool="dreamplace",
- log=SimpleNamespace(file=log_file),
- )
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Imcomplete, "movable utilization is 100.0%")
- raise RuntimeError("run step place failed with state Imcomplete")
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=False,
- step="place",
- idempotency_key="failed-place",
- runner=runner,
- )
-
- status = _wait_for_terminal(manager, started["operationId"])
- assert status["error"] == {
- "code": "tool_failed",
- "message": "movable utilization is 100.0%",
- "step": "place",
- "tool": "dreamplace",
- "logFile": str(log_file),
- }
- assert _wait_for_event(events, "operation.failed")["payload"]["error"] == status["error"]
- second = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=True,
- step="place",
- idempotency_key="retry-place",
- runner=lambda _observer: {"state": "Success"},
- )
- assert _wait_for_terminal(manager, second["operationId"])["state"] == "succeeded"
-
-
-def test_cancel_does_not_replace_a_specific_tool_error(tmp_path):
- manager = RuntimeOperationManager()
- log_file = tmp_path / "place.log"
- step = SimpleNamespace(
- name="place",
- tool="dreamplace",
- log=SimpleNamespace(file=log_file),
- )
- step_failed = threading.Event()
- release_runner = threading.Event()
-
- def runner(observer):
- observer.on_step_started(step)
- observer.on_step_completed(step, StateEnum.Imcomplete, "utilization is larger than 0.99")
- step_failed.set()
- assert release_runner.wait(timeout=2)
- raise RuntimeError("run step place failed with state Incomplete")
-
- started = manager.start(
- workspace_id="workspace-1",
- kind="step",
- origin="gui",
- rerun=False,
- step="place",
- idempotency_key="cancelled-failed-place",
- runner=runner,
- )
- assert step_failed.wait(timeout=1)
- assert manager.request_cancel(started["operationId"])["accepted"] is True
- release_runner.set()
-
- status = _wait_for_terminal(manager, started["operationId"])
- assert status["state"] == "failed"
- assert status["error"] == {
- "code": "tool_failed",
- "message": "utilization is larger than 0.99",
- "step": "place",
- "tool": "dreamplace",
- "logFile": str(log_file),
- }
-
-
-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/test/runtime/test_requests.py b/test/runtime/test_requests.py
deleted file mode 100644
index 93079b6c..00000000
--- a/test/runtime/test_requests.py
+++ /dev/null
@@ -1,352 +0,0 @@
-from dataclasses import is_dataclass
-
-import pytest
-
-from chipcompiler.runtime.methods import runtime_method_by_name
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- FloorplanEditInspectRequest,
- FloorplanEditRunAutoRequest,
- FloorplanEditValidateRequest,
- FlowRunRequest,
- FlowRunStepRequest,
- LayoutEditApplyRequest,
- LayoutEditBeginRequest,
- LayoutEditDiscardRequest,
- LayoutEditSaveRequest,
- OperationStartStepRequest,
- RequestValidationError,
- WorkspaceCloseRequest,
- WorkspaceCreateRequest,
- WorkspaceExportSignoffRequest,
- WorkspaceIdRequest,
- WorkspaceInfoRequest,
- WorkspaceInspectSignoffRequest,
- WorkspaceOpenRequest,
- WorkspaceSyncConfigRequest,
- parse_request_model,
-)
-
-
-def _parse_runtime_request(method: str, params: object, *, persistent_db_enabled=False):
- spec = runtime_method_by_name(method, persistent_db_enabled=persistent_db_enabled)
- assert spec is not None
- return parse_request_model(spec.request_model, params)
-
-
-def test_workspace_create_maps_camel_case_fields_and_preserves_pdk_json():
- pdk_json = {"name": "ics55", "lef": ["tech.lef"]}
- flow_config = {
- "start_step": "Synthesis",
- "end_step": "Harden",
- "steps": ["Synthesis", "RCX", "sta", "Harden"],
- }
-
- request = _parse_runtime_request(
- "workspace.create",
- {
- "directory": "/work/ws",
- "pdk": "ics55",
- "pdkRoot": "/pdk",
- "pdkJson": pdk_json,
- "originDef": "/in.def",
- "originVerilog": "/in.v",
- "paramJson": {"design": "gcd"},
- "rtlList": ["a.v"],
- "sdc": "/constraints/top.sdc",
- "flowConfig": flow_config,
- },
- )
-
- assert isinstance(request, WorkspaceCreateRequest)
- assert is_dataclass(request)
- assert request.directory == "/work/ws"
- assert request.pdk_root == "/pdk"
- assert request.pdk_json == pdk_json
- assert request.origin_def == "/in.def"
- assert request.origin_verilog == "/in.v"
- assert request.parameters == {"design": "gcd"}
- assert request.rtl_list == ["a.v"]
- assert request.sdc == "/constraints/top.sdc"
- assert request.flow_config == flow_config
-
-
-@pytest.mark.parametrize(
- ("method", "params", "request_type"),
- [
- ("workspace.open", {"directory": "/work/ws"}, WorkspaceOpenRequest),
- ("workspace.close", {"workspaceId": "ws-1"}, WorkspaceCloseRequest),
- ("workspace.home", {"workspaceId": "ws-1"}, WorkspaceIdRequest),
- ("workspace.refresh_config", {"workspaceId": "ws-1"}, WorkspaceIdRequest),
- ("workspace.reset_flow", {"workspaceId": "ws-1"}, WorkspaceIdRequest),
- (
- "workspace.export_signoff",
- {"workspaceId": "ws-1", "outputPath": "/exports/custom.tar.gz"},
- WorkspaceExportSignoffRequest,
- ),
- (
- "workspace.inspect_signoff",
- {"workspaceId": "ws-1"},
- WorkspaceInspectSignoffRequest,
- ),
- (
- "workspace.sync_config",
- {"workspaceId": "ws-1", "configPath": "/work/ws/config/route.json"},
- WorkspaceSyncConfigRequest,
- ),
- (
- "workspace.info",
- {"workspaceId": "ws-1", "step": "Synthesis", "id": "layout"},
- WorkspaceInfoRequest,
- ),
- ("flow.run", {"workspaceId": "ws-1", "rerun": True}, FlowRunRequest),
- (
- "flow.run_step",
- {
- "workspaceId": "ws-1",
- "step": "Synthesis",
- "rerun": True,
- },
- FlowRunStepRequest,
- ),
- (
- "operation.start_step",
- {
- "workspaceId": "ws-1",
- "step": "Synthesis",
- "resetDependents": True,
- },
- OperationStartStepRequest,
- ),
- ],
-)
-def test_first_slice_payloads_parse_to_typed_request_models(method, params, request_type):
- request = _parse_runtime_request(method, params)
-
- assert isinstance(request, request_type)
- assert is_dataclass(request)
-
-
-@pytest.mark.parametrize(
- ("method", "params", "request_type"),
- [
- (
- "db.ensure",
- {"workspaceId": "ws-1", "step": "Floorplan"},
- DbEnsureRequest,
- ),
- ("db.ensure", {"workspaceId": "ws-1"}, DbEnsureRequest),
- ("db.release", {"workspaceId": "ws-1"}, DbReleaseRequest),
- (
- "layout.edit.begin",
- {"workspaceId": "ws-1", "step": "Floorplan"},
- LayoutEditBeginRequest,
- ),
- (
- "layout.edit.apply",
- {
- "editSessionId": "layout-edit-1",
- "commandId": "move-1",
- "baseRevision": 0,
- "operation": {"kind": "place_instance"},
- },
- LayoutEditApplyRequest,
- ),
- (
- "layout.edit.save",
- {"editSessionId": "layout-edit-1", "expectedRevision": 1},
- LayoutEditSaveRequest,
- ),
- (
- "layout.edit.discard",
- {"editSessionId": "layout-edit-1"},
- LayoutEditDiscardRequest,
- ),
- (
- "floorplan.edit.inspect",
- {"editSessionId": "layout-edit-1"},
- FloorplanEditInspectRequest,
- ),
- (
- "floorplan.edit.run_auto",
- {
- "editSessionId": "layout-edit-1",
- "commandId": "auto-1",
- "baseRevision": 1,
- "request": {"mode": "macro"},
- },
- FloorplanEditRunAutoRequest,
- ),
- (
- "floorplan.edit.validate",
- {"editSessionId": "layout-edit-1", "scope": "pdn"},
- FloorplanEditValidateRequest,
- ),
- ],
-)
-def test_persistent_db_payloads_parse_to_typed_request_models(method, params, request_type):
- request = _parse_runtime_request(method, params, persistent_db_enabled=True)
-
- assert isinstance(request, request_type)
- assert is_dataclass(request)
- if hasattr(request, "workspace_id"):
- assert request.workspace_id == "ws-1"
- else:
- assert request.edit_session_id == "layout-edit-1"
-
-
-def test_db_ensure_step_is_optional():
- request = _parse_runtime_request(
- "db.ensure",
- {"workspaceId": "ws-1"},
- persistent_db_enabled=True,
- )
-
- assert isinstance(request, DbEnsureRequest)
- assert request.step == ""
-
-
-def test_layout_edit_begin_accepts_source_fingerprint_alias():
- request = _parse_runtime_request(
- "layout.edit.begin",
- {
- "workspaceId": "ws-1",
- "step": "Floorplan",
- "expectedSourceFingerprint": "abc123",
- },
- persistent_db_enabled=True,
- )
-
- assert isinstance(request, LayoutEditBeginRequest)
- assert request.expected_source_fingerprint == "abc123"
-
-
-def test_missing_required_field_reports_field_name():
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request("flow.run_step", {"workspaceId": "ws-1"})
-
- assert exc_info.value.reason == "missing required field: step"
-
-
-def test_unknown_fields_are_rejected():
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request("workspace.open", {"directory": "/work/ws", "extra": True})
-
- assert exc_info.value.reason == "unknown field: extra"
-
-
-def test_db_method_unknown_fields_are_rejected():
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request(
- "db.ensure",
- {"workspaceId": "ws-1", "extra": True},
- persistent_db_enabled=True,
- )
-
- assert exc_info.value.reason == "unknown field: extra"
-
-
-def test_db_method_blank_workspace_id_is_rejected():
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request(
- "db.release",
- {"workspaceId": " "},
- persistent_db_enabled=True,
- )
-
- assert exc_info.value.reason == "missing required field: workspace_id"
-
-
-def test_params_must_be_an_object():
- with pytest.raises(RequestValidationError, match="params must be an object"):
- _parse_runtime_request("workspace.open", None)
-
-
-def test_workspace_info_accepts_info_id_alias():
- request = _parse_runtime_request(
- "workspace.info",
- {"workspaceId": "ws-1", "step": "Synthesis", "infoId": "timing"},
- )
-
- assert isinstance(request, WorkspaceInfoRequest)
- assert request.info_id == "timing"
-
-
-def test_workspace_export_signoff_preserves_exact_output_path():
- request = _parse_runtime_request(
- "workspace.export_signoff",
- {
- "workspaceId": "ws-1",
- "outputPath": "/exports/custom.tar.gz ",
- },
- )
-
- assert isinstance(request, WorkspaceExportSignoffRequest)
- assert request.output_path == "/exports/custom.tar.gz "
-
-
-@pytest.mark.parametrize(
- "additional_files",
- [
- "not-a-list",
- [{"archivePath": "nested.txt"}],
- [{"content": "missing path"}],
- [{"archivePath": "nested.txt", "content": 42}],
- ],
-)
-def test_workspace_export_signoff_validates_additional_files(additional_files):
- with pytest.raises(RequestValidationError):
- _parse_runtime_request(
- "workspace.export_signoff",
- {
- "workspaceId": "ws-1",
- "outputPath": "/exports/custom.tar.gz",
- "additionalFiles": additional_files,
- },
- )
-
-
-@pytest.mark.parametrize(
- ("method", "params"),
- [
- ("flow.run", {"workspaceId": "ws-1", "rerun": "false"}),
- ("flow.run_step", {"workspaceId": "ws-1", "step": "Synthesis", "rerun": "true"}),
- ],
-)
-def test_rerun_must_be_boolean(method, params):
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request(method, params)
-
- assert exc_info.value.reason == "rerun must be a boolean"
-
-
-@pytest.mark.parametrize(
- ("method", "params"),
- [
- (
- "operation.start_step",
- {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": 1},
- ),
- ],
-)
-def test_reset_dependents_must_be_boolean(method, params):
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request(method, params)
-
- assert exc_info.value.reason == "reset_dependents must be a boolean"
-
-
-def test_direct_flow_run_step_rejects_gui_only_reset_dependents_field():
- with pytest.raises(RequestValidationError) as exc_info:
- _parse_runtime_request(
- "flow.run_step",
- {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": True},
- )
-
- assert exc_info.value.reason == "unknown field: reset_dependents"
-
-
-def test_unknown_runtime_method_has_no_request_model():
- assert runtime_method_by_name("workspace.signoff") is None
- assert runtime_method_by_name("db.ensure") is None
diff --git a/test/runtime/test_rpc_create_parameters.py b/test/runtime/test_rpc_create_parameters.py
deleted file mode 100644
index 5027b375..00000000
--- a/test/runtime/test_rpc_create_parameters.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env python
-
-"""RPC creation path: GUI flat parameters are effective at workspace creation."""
-
-from types import SimpleNamespace
-
-from chipcompiler.data.parameter import load_parameter
-from chipcompiler.runtime.requests import WorkspaceCreateRequest
-from chipcompiler.runtime.workspace_api import WorkspaceRuntimeApi
-
-
-def _make_api(monkeypatch):
- monkeypatch.setattr(
- "chipcompiler.runtime.workspace_api.build_flow_for_workspace",
- lambda _workspace: SimpleNamespace(),
- )
- return WorkspaceRuntimeApi()
-
-
-def _create(api, workspace_dir, pdk_root, parameters):
- tech = pdk_root / "tech.lef"
- lef = pdk_root / "stdcell.lef"
- liberty = pdk_root / "stdcell.lib"
- for path in (tech, lef, liberty):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("VERSION 5.8 ;\n")
-
- return api.create_workspace(
- WorkspaceCreateRequest(
- directory=str(workspace_dir),
- pdk="ics55",
- pdk_json={
- "name": "ics55",
- "root": str(pdk_root),
- "tech": str(tech),
- "lefs": [str(lef)],
- "libs": [str(liberty)],
- },
- parameters=parameters,
- )
- )
-
-
-def test_gui_flat_parameters_are_effective_at_creation(monkeypatch, tmp_path):
- api = _make_api(monkeypatch)
- workspace_dir = tmp_path / "workspace"
-
- _create(
- api,
- workspace_dir,
- tmp_path / "pdk",
- {"frequency_max": 200, "top_module": "gcd", "design": "gcd", "clock": "clk"},
- )
-
- persisted = load_parameter(workspace_dir / "home" / "params.toml").data
- assert persisted["frequency_max"] == 200
- assert persisted["top_module"] == "gcd"
- assert persisted["design"] == "gcd"
- assert persisted["clock"] == "clk"
-
-
-def test_gui_geometry_aliases_fold_into_subtrees(monkeypatch, tmp_path):
- api = _make_api(monkeypatch)
- workspace_dir = tmp_path / "workspace"
-
- _create(
- api,
- workspace_dir,
- tmp_path / "pdk",
- {
- "design": "gcd",
- "top_module": "gcd",
- "clock": "clk",
- "die_width": 150,
- "die_height": 160,
- "utilitization": 0.5,
- "margin": 3,
- "die_area_mode": "width_height",
- },
- )
-
- persisted = load_parameter(workspace_dir / "home" / "params.toml").data
- assert persisted["die"]["size"] == [150, 160]
- assert persisted["core"]["utilitization"] == 0.5
- assert persisted["core"]["margin"] == [3, 3]
- # The GUI-only mode key never lands in the persisted configuration.
- assert "die_area_mode" not in persisted
- assert "die_width" not in persisted
-
-
-def test_legacy_long_keys_in_rpc_payload_are_normalized(monkeypatch, tmp_path):
- api = _make_api(monkeypatch)
- workspace_dir = tmp_path / "workspace"
-
- _create(
- api,
- workspace_dir,
- tmp_path / "pdk",
- {"Design": "gcd", "Top module": "gcd", "Clock": "clk", "Frequency max [MHz]": 300},
- )
-
- persisted = load_parameter(workspace_dir / "home" / "params.toml").data
- assert persisted["frequency_max"] == 300
- assert "Frequency max [MHz]" not in persisted
-
-
-def test_gui_geometry_reaches_floorplan_config(monkeypatch, tmp_path):
- import json
-
- api = _make_api(monkeypatch)
- workspace_dir = tmp_path / "workspace"
-
- _create(
- api,
- workspace_dir,
- tmp_path / "pdk",
- {
- "design": "gcd",
- "top_module": "gcd",
- "clock": "clk",
- "die_width": 150,
- "die_height": 160,
- "utilitization": 0.5,
- "margin": 3,
- "die_area_mode": "width_height",
- },
- )
-
- floorplan = json.loads((workspace_dir / "config" / "floorplan_ecc.json").read_text())
- die_builder = floorplan["die_builder"]
- assert die_builder["mode"] == "die_size"
- assert die_builder["die_size"]["width_micron"] == 150
- assert die_builder["die_size"]["height_micron"] == 160
- assert die_builder["margin"]["left_micron"] == 3
- assert die_builder["margin"]["top_micron"] == 3
- assert die_builder["die_util"]["utilization"] == 0.5
diff --git a/test/runtime/test_rpc_dispatch.py b/test/runtime/test_rpc_dispatch.py
deleted file mode 100644
index 86691e15..00000000
--- a/test/runtime/test_rpc_dispatch.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import json
-
-from chipcompiler.runtime.rpc_dispatch import RpcDispatcher
-
-
-def _dispatch(dispatcher: RpcDispatcher, payload: str) -> dict:
- return json.loads(dispatcher.dispatch(payload))
-
-
-def test_dispatch_registered_method_returns_standard_success_response():
- dispatcher = RpcDispatcher()
- dispatcher.add_method("rpc.ping", lambda: {"ok": True})
-
- response = _dispatch(
- dispatcher,
- '{"jsonrpc":"2.0","method":"rpc.ping","id":1}',
- )
-
- assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 1}
-
-
-def test_unknown_method_returns_standard_method_not_found_error():
- dispatcher = RpcDispatcher()
-
- response = _dispatch(
- dispatcher,
- '{"jsonrpc":"2.0","method":"missing","id":"req-1"}',
- )
-
- assert response["jsonrpc"] == "2.0"
- assert response["id"] == "req-1"
- assert response["error"]["code"] == -32601
- assert response["error"]["message"] == "Method not found"
-
-
-def test_invalid_params_return_standard_invalid_params_error():
- dispatcher = RpcDispatcher()
-
- def needs_name(name: str) -> dict:
- return {"name": name}
-
- dispatcher.add_method("needsName", needs_name)
-
- response = _dispatch(
- dispatcher,
- '{"jsonrpc":"2.0","method":"needsName","params":{"missing":"x"},"id":2}',
- )
-
- assert response["id"] == 2
- assert response["error"]["code"] == -32602
- assert response["error"]["message"] == "Invalid params"
-
-
-def test_parse_error_uses_json_rpc_parse_error_shape():
- dispatcher = RpcDispatcher()
-
- response = _dispatch(dispatcher, "{")
-
- assert response["id"] is None
- assert response["error"]["code"] == -32700
- assert response["error"]["message"] == "Parse error"
-
-
-def test_custom_non_json_rpc_envelope_is_rejected():
- dispatcher = RpcDispatcher()
- dispatcher.add_method("rpc.ping", lambda: {"ok": True})
-
- response = _dispatch(dispatcher, '{"type":"request","method":"rpc.ping","id":1}')
-
- assert response["id"] is None
- assert response["error"]["code"] == -32600
diff --git a/test/runtime/test_server.py b/test/runtime/test_server.py
deleted file mode 100644
index 34393f85..00000000
--- a/test/runtime/test_server.py
+++ /dev/null
@@ -1,429 +0,0 @@
-import json
-
-import pytest
-
-from chipcompiler.runtime.methods import RUNTIME_METHODS, runtime_methods
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- WorkspaceExportSignoffRequest,
- WorkspaceInspectSignoffRequest,
- WorkspaceOpenRequest,
-)
-from chipcompiler.runtime.server import RuntimeServer
-from chipcompiler.runtime.workspace_api import RuntimeApiError
-
-
-def _dispatch(server: RuntimeServer, payload: str) -> dict:
- return json.loads(server.dispatch(payload))
-
-
-class CompleteFakeApi:
- def create_workspace(self, _request):
- raise AssertionError("unexpected create_workspace call")
-
- def open_workspace(self, _request):
- raise AssertionError("unexpected open_workspace call")
-
- def close_workspace(self, _request):
- raise AssertionError("unexpected close_workspace call")
-
- def workspace_home(self, _request):
- raise AssertionError("unexpected workspace_home call")
-
- def workspace_info(self, _request):
- raise AssertionError("unexpected workspace_info call")
-
- def refresh_config(self, _request):
- raise AssertionError("unexpected refresh_config call")
-
- def sync_config(self, _request):
- raise AssertionError("unexpected sync_config call")
-
- def reset_flow(self, _request):
- raise AssertionError("unexpected reset_flow call")
-
- def export_signoff(self, _request):
- raise AssertionError("unexpected export_signoff call")
-
- def inspect_signoff(self, _request):
- raise AssertionError("unexpected inspect_signoff call")
-
- def flow_run(self, _request):
- raise AssertionError("unexpected flow_run call")
-
- def flow_run_step(self, _request):
- raise AssertionError("unexpected flow_run_step call")
-
- def start_flow_operation(self, _request):
- raise AssertionError("unexpected start_flow_operation call")
-
- def start_step_operation(self, _request):
- raise AssertionError("unexpected start_step_operation call")
-
- def operation_status(self, _request):
- raise AssertionError("unexpected operation_status call")
-
- def cancel_operation(self, _request):
- raise AssertionError("unexpected cancel_operation call")
-
- def acknowledge_step_rendered(self, _request):
- raise AssertionError("unexpected acknowledge_step_rendered call")
-
- def workspace_snapshot(self, _request):
- raise AssertionError("unexpected workspace_snapshot call")
-
- def recover_interrupted(self, _request):
- raise AssertionError("unexpected recover_interrupted call")
-
- def db_ensure(self, _request):
- raise AssertionError("unexpected db_ensure call")
-
- def db_release(self, _request):
- raise AssertionError("unexpected db_release call")
-
- def layout_edit_begin(self, _request):
- raise AssertionError("unexpected layout_edit_begin call")
-
- def layout_edit_apply(self, _request):
- raise AssertionError("unexpected layout_edit_apply call")
-
- def layout_edit_save(self, _request):
- raise AssertionError("unexpected layout_edit_save call")
-
- def layout_edit_discard(self, _request):
- raise AssertionError("unexpected layout_edit_discard call")
-
- def floorplan_edit_inspect(self, _request):
- raise AssertionError("unexpected floorplan_edit_inspect call")
-
- def floorplan_edit_run_auto(self, _request):
- raise AssertionError("unexpected floorplan_edit_run_auto call")
-
- def floorplan_edit_validate(self, _request):
- raise AssertionError("unexpected floorplan_edit_validate call")
-
-
-def test_rpc_hello_returns_version_and_capabilities():
- server = RuntimeServer()
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}',
- )
-
- assert response["id"] == "hello"
- assert response["result"]["version"] == 1
- assert response["result"]["eccVersion"]
- assert "rpc.ping" in response["result"]["capabilities"]
- assert "rpc.shutdown" in response["result"]["capabilities"]
- assert "runtime.v2" in response["result"]["capabilities"]
- assert "operation.events" in response["result"]["capabilities"]
- assert "workspace.snapshot" in response["result"]["capabilities"]
- assert "db.ensure" not in response["result"]["capabilities"]
- assert "db.release" not in response["result"]["capabilities"]
-
-
-def test_rpc_hello_reports_persistent_db_capabilities_when_enabled():
- server = RuntimeServer(persistent_db_enabled=True)
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}',
- )
-
- assert "db.ensure" in response["result"]["capabilities"]
- assert "db.release" in response["result"]["capabilities"]
-
-
-def test_rpc_hello_rejects_incompatible_version():
- server = RuntimeServer()
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":2},"id":1}',
- )
-
- assert response["id"] == 1
- assert response["error"]["code"] == -32001
- assert response["error"]["message"] == "unsupported_version"
-
-
-def test_rpc_ping_returns_correlated_result():
- server = RuntimeServer()
-
- response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.ping","id":"p"}')
-
- assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": "p"}
-
-
-def test_rpc_shutdown_marks_server_for_graceful_exit():
- server = RuntimeServer()
-
- response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}')
-
- assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3}
- assert server.should_exit
-
-
-def test_rpc_shutdown_releases_runtime_sessions():
- class FakeSessions:
- def __init__(self):
- self.closed = False
-
- def close_all(self):
- self.closed = True
-
- class FakeApi(CompleteFakeApi):
- sessions = FakeSessions()
-
- api = FakeApi()
- server = RuntimeServer(api=api)
-
- response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}')
-
- assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3}
- assert api.sessions.closed
-
-
-def test_unknown_method_keeps_request_id():
- server = RuntimeServer()
-
- response = _dispatch(server, '{"jsonrpc":"2.0","method":"missing","id":"req"}')
-
- assert response["id"] == "req"
- assert response["error"]["code"] == -32601
-
-
-def test_workspace_method_dispatches_typed_request_to_runtime_api():
- class FakeApi(CompleteFakeApi):
- def open_workspace(self, request):
- assert isinstance(request, WorkspaceOpenRequest)
- return {"workspaceId": "workspace-1", "directory": request.directory}
-
- server = RuntimeServer(api=FakeApi())
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":4}',
- )
-
- assert response == {
- "jsonrpc": "2.0",
- "result": {"workspaceId": "workspace-1", "directory": "/ws"},
- "id": 4,
- }
-
-
-def test_workspace_export_signoff_dispatches_exact_output_path():
- class FakeApi(CompleteFakeApi):
- def export_signoff(self, request):
- assert isinstance(request, WorkspaceExportSignoffRequest)
- return {"outputPath": request.output_path}
-
- server = RuntimeServer(api=FakeApi())
-
- response = _dispatch(
- server,
- (
- '{"jsonrpc":"2.0","method":"workspace.export_signoff",'
- '"params":{"workspaceId":"workspace-1",'
- '"outputPath":"/exports/custom.tar.gz "},"id":5}'
- ),
- )
-
- assert response == {
- "jsonrpc": "2.0",
- "result": {"outputPath": "/exports/custom.tar.gz "},
- "id": 5,
- }
-
-
-def test_workspace_inspect_signoff_dispatches_typed_request():
- class FakeApi(CompleteFakeApi):
- def inspect_signoff(self, request):
- assert isinstance(request, WorkspaceInspectSignoffRequest)
- return {"status": "ready", "groups": [], "risks": []}
-
- server = RuntimeServer(api=FakeApi())
-
- response = _dispatch(
- server,
- (
- '{"jsonrpc":"2.0","method":"workspace.inspect_signoff",'
- '"params":{"workspaceId":"workspace-1"},"id":6}'
- ),
- )
-
- assert response == {
- "jsonrpc": "2.0",
- "result": {"status": "ready", "groups": [], "risks": []},
- "id": 6,
- }
-
-
-def test_persistent_db_methods_dispatch_typed_requests_to_runtime_api():
- seen = []
-
- class FakeApi(CompleteFakeApi):
- def db_ensure(self, request):
- seen.append(request)
- assert isinstance(request, DbEnsureRequest)
- return {
- "workspaceId": request.workspace_id,
- "enabled": True,
- "active": True,
- "reused": False,
- "step": request.step,
- }
-
- def db_release(self, request):
- seen.append(request)
- assert isinstance(request, DbReleaseRequest)
- return {"workspaceId": request.workspace_id, "released": True}
-
- server = RuntimeServer(api=FakeApi(), persistent_db_enabled=True)
-
- ensure_response = _dispatch(
- server,
- (
- '{"jsonrpc":"2.0","method":"db.ensure",'
- '"params":{"workspaceId":"workspace-1","step":"Floorplan"},"id":8}'
- ),
- )
- release_response = _dispatch(
- server,
- ('{"jsonrpc":"2.0","method":"db.release","params":{"workspaceId":"workspace-1"},"id":9}'),
- )
-
- assert ensure_response["result"] == {
- "workspaceId": "workspace-1",
- "enabled": True,
- "active": True,
- "reused": False,
- "step": "Floorplan",
- }
- assert release_response["result"] == {
- "workspaceId": "workspace-1",
- "released": True,
- }
- assert [type(request) for request in seen] == [DbEnsureRequest, DbReleaseRequest]
-
-
-def test_persistent_db_methods_are_not_registered_by_default():
- server = RuntimeServer()
-
- response = _dispatch(
- server,
- ('{"jsonrpc":"2.0","method":"db.ensure","params":{"workspaceId":"workspace-1"},"id":10}'),
- )
-
- assert response["id"] == 10
- assert response["error"]["code"] == -32601
-
-
-def test_request_validation_errors_map_to_json_rpc_invalid_params():
- server = RuntimeServer()
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"workspace.home","params":{"directory":"/ws"},"id":5}',
- )
-
- assert response["id"] == 5
- assert response["error"]["code"] == -32602
- assert response["error"]["message"] == "invalid_request"
- assert response["error"]["data"]["message"] == "unknown field: directory"
-
-
-def test_workspace_session_errors_map_to_json_rpc_runtime_error():
- class FakeApi(CompleteFakeApi):
- def workspace_home(self, _request):
- raise RuntimeApiError(
- "workspace_session_not_found",
- "workspace session not found: missing",
- )
-
- server = RuntimeServer(api=FakeApi())
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"workspace.home","params":{"workspaceId":"missing"},"id":6}',
- )
-
- assert response["id"] == 6
- assert response["error"]["code"] == -32010
- assert response["error"]["message"] == "workspace_session_not_found"
-
-
-def test_workspace_api_user_exceptions_map_to_command_failed():
- class FakeApi(CompleteFakeApi):
- def open_workspace(self, _request):
- raise ValueError("PDK tech LEF is missing")
-
- server = RuntimeServer(api=FakeApi())
-
- response = _dispatch(
- server,
- '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":7}',
- )
-
- assert response["id"] == 7
- assert response["error"]["code"] == -32020
- assert response["error"]["message"] == "command_failed"
- assert response["error"]["data"]["message"] == "PDK tech LEF is missing"
-
-
-@pytest.mark.parametrize(
- "method",
- [spec.method_name for spec in RUNTIME_METHODS],
-)
-def test_first_slice_methods_are_registered(method):
- server = RuntimeServer()
-
- response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}')
-
- assert response["error"]["code"] != -32601
-
-
-@pytest.mark.parametrize(
- "method",
- [spec.method_name for spec in runtime_methods(persistent_db_enabled=True)],
-)
-def test_enabled_persistent_db_runtime_methods_are_registered(method):
- server = RuntimeServer(api=CompleteFakeApi(), persistent_db_enabled=True)
-
- response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}')
-
- assert response["error"]["code"] != -32601
-
-
-def test_runtime_server_fails_when_registered_api_handler_is_missing(monkeypatch):
- from chipcompiler.runtime import methods
-
- missing_spec = methods.RuntimeMethodSpec(
- method_name="workspace.missing_handler",
- request_model=WorkspaceOpenRequest,
- handler_name="missing_handler",
- )
- monkeypatch.setattr(methods, "RUNTIME_METHODS", (missing_spec,))
-
- with pytest.raises(TypeError, match="missing_handler"):
- RuntimeServer()
-
-
-def test_runtime_server_fails_when_registered_api_handler_is_not_callable(monkeypatch):
- from chipcompiler.runtime import methods
-
- class FakeApi:
- open_workspace = object()
-
- spec = methods.RuntimeMethodSpec(
- method_name="workspace.open",
- request_model=WorkspaceOpenRequest,
- handler_name="open_workspace",
- )
- monkeypatch.setattr(methods, "RUNTIME_METHODS", (spec,))
-
- with pytest.raises(TypeError, match="open_workspace"):
- RuntimeServer(api=FakeApi())
diff --git a/test/runtime/test_sessions.py b/test/runtime/test_sessions.py
deleted file mode 100644
index 29731ed9..00000000
--- a/test/runtime/test_sessions.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from pathlib import Path
-
-import pytest
-
-from chipcompiler.runtime.sessions import WorkspaceSessionNotFound, WorkspaceSessionRegistry
-
-
-def test_create_session_returns_workspace_id_and_resolved_directory(tmp_path):
- registry = WorkspaceSessionRegistry()
- workspace = object()
-
- session = registry.create_session(tmp_path / "ws", workspace=workspace)
-
- assert session.workspace_id.startswith("workspace-")
- assert session.directory == (tmp_path / "ws").resolve()
- assert session.workspace is workspace
- assert session.db_handle is None
-
-
-def test_open_reuses_existing_session_for_same_directory(tmp_path):
- registry = WorkspaceSessionRegistry()
-
- first = registry.open_session(tmp_path / "ws", workspace="first")
- second = registry.open_session(Path(tmp_path / "ws"), workspace="second")
-
- assert second.workspace_id == first.workspace_id
- assert second.workspace == "first"
-
-
-def test_create_replaces_existing_session_for_same_directory(tmp_path):
- released = []
- registry = WorkspaceSessionRegistry(db_releaser=released.append)
-
- first = registry.open_session(tmp_path / "ws", workspace="first")
- db_handle = object()
- first.db_handle = db_handle
- second = registry.create_session(Path(tmp_path / "ws"), workspace="second")
-
- assert second.workspace_id != first.workspace_id
- assert second.directory == first.directory
- assert second.workspace == "second"
- assert first.db_handle is None
- assert released == [db_handle]
- assert registry.get_session(second.workspace_id) is second
- with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id):
- registry.get_session(first.workspace_id)
- assert registry.open_session(tmp_path / "ws", workspace="third") is second
-
-
-def test_get_session_rejects_unknown_and_closed_workspace_id(tmp_path):
- registry = WorkspaceSessionRegistry()
- session = registry.open_session(tmp_path / "ws", workspace=object())
-
- registry.close_session(session.workspace_id)
-
- with pytest.raises(WorkspaceSessionNotFound, match=session.workspace_id):
- registry.get_session(session.workspace_id)
- with pytest.raises(WorkspaceSessionNotFound, match="missing"):
- registry.get_session("missing")
-
-
-def test_close_session_releases_active_db_handle_once(tmp_path):
- released = []
- registry = WorkspaceSessionRegistry(db_releaser=released.append)
- session = registry.open_session(tmp_path / "ws", workspace=object())
- db_handle = object()
- session.db_handle = db_handle
-
- registry.close_session(session.workspace_id)
-
- assert session.db_handle is None
- assert released == [db_handle]
-
-
-def test_close_all_releases_all_active_db_handles_and_clears_directory_map(tmp_path):
- released = []
- registry = WorkspaceSessionRegistry(db_releaser=released.append)
- first = registry.open_session(tmp_path / "first", workspace=object())
- second = registry.open_session(tmp_path / "second", workspace=object())
- first_db = object()
- second_db = object()
- first.db_handle = first_db
- second.db_handle = second_db
-
- registry.close_all()
-
- assert first.db_handle is None
- assert second.db_handle is None
- assert released == [first_db, second_db]
- with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id):
- registry.get_session(first.workspace_id)
- reopened = registry.open_session(tmp_path / "first", workspace="new")
- assert reopened.workspace == "new"
-
-
-def test_per_session_lock_serializes_mutating_commands(tmp_path):
- registry = WorkspaceSessionRegistry()
- session = registry.open_session(tmp_path / "ws", workspace=object())
-
- with session.mutation_lock:
- assert not session.mutation_lock.acquire(blocking=False)
diff --git a/test/runtime/test_signoff_export.py b/test/runtime/test_signoff_export.py
deleted file mode 100644
index 0ecc11ad..00000000
--- a/test/runtime/test_signoff_export.py
+++ /dev/null
@@ -1,448 +0,0 @@
-import json
-import queue
-import threading
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-
-from chipcompiler.runtime import signoff_export
-from chipcompiler.runtime.requests import (
- WorkspaceExportSignoffRequest,
- WorkspaceInspectSignoffRequest,
-)
-from chipcompiler.runtime.sessions import WorkspaceSessionRegistry
-from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi
-
-
-def test_workspace_export_signoff_returns_exact_output_path(monkeypatch, tmp_path):
- workspace = SimpleNamespace(directory=tmp_path / "workspace")
- sessions = WorkspaceSessionRegistry()
- session = sessions.open_session(workspace.directory, workspace=workspace)
- output_path = tmp_path / "exports" / "custom name.tar.gz"
- calls = []
-
- def fake_export(active_workspace, requested_output, additional_files=None):
- calls.append((active_workspace, requested_output))
- return str(output_path)
-
- monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.export_signoff_package_archive",
- fake_export,
- )
- api = WorkspaceRuntimeApi(sessions=sessions)
-
- result = api.export_signoff(
- WorkspaceExportSignoffRequest(
- workspace_id=session.workspace_id,
- output_path=str(output_path),
- )
- )
-
- assert result == {"outputPath": str(output_path)}
- assert calls == [(workspace, str(output_path))]
-
-
-def test_workspace_export_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path):
- workspace = SimpleNamespace(directory=tmp_path / "workspace")
- sessions = WorkspaceSessionRegistry()
- session = sessions.open_session(workspace.directory, workspace=workspace)
- output_path = tmp_path / "export.tar.gz"
- entered = threading.Event()
- results = queue.Queue()
-
- def fake_export(_workspace, requested_output, additional_files=None):
- entered.set()
- return requested_output
-
- monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.export_signoff_package_archive",
- fake_export,
- )
- api = WorkspaceRuntimeApi(sessions=sessions)
-
- def run_export():
- try:
- results.put(
- api.export_signoff(
- WorkspaceExportSignoffRequest(
- workspace_id=session.workspace_id,
- output_path=str(output_path),
- )
- )
- )
- except BaseException as error: # pragma: no cover - re-raised below
- results.put(error)
-
- with session.mutation_lock:
- worker = threading.Thread(target=run_export)
- worker.start()
- assert not entered.wait(0.1)
- assert worker.is_alive()
-
- worker.join(timeout=2)
- assert not worker.is_alive()
- result = results.get_nowait()
- if isinstance(result, BaseException):
- raise result
- assert result == {"outputPath": str(output_path)}
- assert entered.is_set()
-
-
-def test_inspect_signoff_package_reads_current_home_checklist(monkeypatch, tmp_path):
- workspace_dir = tmp_path / "workspace"
- checklist_path = workspace_dir / "home" / "checklist.json"
- checklist_path.parent.mkdir(parents=True)
- checklist_path.write_text(
- json.dumps(
- {
- "schema_version": 3,
- "kind": "signoff_checklist",
- "status": "blocked",
- "summary": {"passed": 1, "blocked": 1, "attention": 1, "unavailable": 0},
- "checklist": [
- {
- "id": "quality.drc.clean",
- "step": "drc",
- "category": "quality_gate",
- "owner": "qor",
- "policy": "block",
- "state": "failed",
- "blocked": True,
- "title": "Final DRC clean",
- "summary": "drc_count=2 (required == 0)",
- "source": {
- "kind": "qor_gate",
- "path": "drc_ecc/analysis/qor_summary.json",
- "gate_id": "qor.drc.clean",
- },
- "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}],
- },
- {
- "id": "report.optional.image",
- "step": "workspace",
- "category": "report",
- "owner": "checklist",
- "policy": "warn",
- "state": "warning",
- "blocked": False,
- "title": "Optional image",
- "summary": "Optional image is missing.",
- "source": {"kind": "package", "path": "filler_ecc/output/gcd_filler.png"},
- "evidence": [],
- },
- {
- "id": "provenance.initial.rtl",
- "step": "workspace",
- "category": "provenance",
- "owner": "checklist",
- "policy": "block",
- "state": "pass",
- "blocked": False,
- "title": "Initial RTL",
- "summary": "Current output is present and non-empty.",
- "source": {"kind": "provenance", "path": "origin/gcd.v"},
- "evidence": [],
- },
- ],
- }
- ),
- encoding="utf-8",
- )
-
- class FakeFlow:
- def __init__(self, workspace):
- assert workspace.directory == workspace_dir
-
- def collect_signoff_package(self, options):
- assert options.archive is False
- assert options.materialize is False
- return SimpleNamespace()
-
- monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow)
-
- review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir))
-
- assert review["status"] == "blocked"
- assert [group["id"] for group in review["groups"]] == [
- "initial",
- "config",
- "harden",
- "final_design",
- "sta",
- "spef",
- "reports",
- ]
- drc_group = next(group for group in review["groups"] if group["id"] == "final_design")
- assert drc_group == {
- "id": "final_design",
- "label": "Final Design",
- "status": "blocked",
- "available": 0,
- "expected": 2,
- "summary": "1 blocking checklist requirements",
- }
- reports_group = next(group for group in review["groups"] if group["id"] == "reports")
- assert reports_group["status"] == "ready"
- assert [risk["severity"] for risk in review["risks"]] == ["blocked", "warning"]
- blocked_risk = next(risk for risk in review["risks"] if risk["severity"] == "blocked")
- assert blocked_risk["details"] == [
- {
- "kind": "quality_gate",
- "label": "Final DRC clean",
- "location": "drc_ecc/analysis/qor_summary.json",
- "reason": "drc_count=2 (required == 0)",
- "owner": "qor",
- "policy": "block",
- "state": "failed",
- "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}],
- }
- ]
-
-
-def test_inspect_signoff_package_blocks_when_current_checklist_is_unavailable(
- monkeypatch, tmp_path
-):
- workspace_dir = tmp_path / "workspace"
- (workspace_dir / "home").mkdir(parents=True)
-
- class FakeFlow:
- def __init__(self, workspace):
- assert workspace.directory == workspace_dir
-
- def collect_signoff_package(self, options):
- return SimpleNamespace()
-
- monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow)
-
- review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir))
-
- assert review["status"] == "blocked"
- assert review["risks"][0]["title"] == "Signoff checklist unavailable"
-
-
-def test_inspect_signoff_package_blocks_when_checklist_is_not_an_object(monkeypatch, tmp_path):
- workspace_dir = tmp_path / "workspace"
- home_dir = workspace_dir / "home"
- home_dir.mkdir(parents=True)
- (home_dir / "checklist.json").write_text("[]", encoding="utf-8")
-
- class FakeFlow:
- def __init__(self, workspace):
- assert workspace.directory == workspace_dir
-
- def collect_signoff_package(self, options):
- return SimpleNamespace()
-
- monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow)
-
- review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir))
-
- assert review["status"] == "blocked"
- assert review["risks"][0]["title"] == "Signoff checklist unavailable"
-
-
-def test_workspace_inspect_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path):
- workspace = SimpleNamespace(directory=tmp_path / "workspace")
- sessions = WorkspaceSessionRegistry()
- session = sessions.open_session(workspace.directory, workspace=workspace)
- entered = threading.Event()
- results = queue.Queue()
-
- def fake_inspect(active_workspace):
- assert active_workspace is workspace
- entered.set()
- return {"status": "ready", "groups": [], "risks": []}
-
- monkeypatch.setattr(signoff_export, "inspect_signoff_package", fake_inspect)
- api = WorkspaceRuntimeApi(sessions=sessions)
-
- def run_inspection():
- try:
- results.put(
- api.inspect_signoff(
- WorkspaceInspectSignoffRequest(workspace_id=session.workspace_id)
- )
- )
- except BaseException as error: # pragma: no cover - re-raised below
- results.put(error)
-
- with session.mutation_lock:
- worker = threading.Thread(target=run_inspection)
- worker.start()
- assert not entered.wait(0.1)
- assert worker.is_alive()
-
- worker.join(timeout=2)
- assert not worker.is_alive()
- result = results.get_nowait()
- if isinstance(result, BaseException):
- raise result
- assert result == {"status": "ready", "groups": [], "risks": []}
-
-
-def test_export_signoff_package_archive_collects_temporarily_and_replaces_atomically(
- monkeypatch,
- tmp_path,
-):
- from chipcompiler.runtime.signoff_export import export_signoff_package_archive
-
- output_path = tmp_path / "nested" / "chosen.tar.gz"
- captured_output_dirs = []
-
- class FakeFlow:
- def __init__(self, workspace):
- assert workspace == "workspace"
-
- def collect_signoff_package(self, options):
- captured_output_dirs.append(options.output_dir)
- package_dir = Path(options.output_dir) / "design_signoff_package"
- package_dir.mkdir(parents=True, exist_ok=True)
- (package_dir / "dummy.txt").write_text("archive")
- return SimpleNamespace(
- ok=True,
- package_dir=str(package_dir),
- missing_required=[],
- )
-
- monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.EngineFlow",
- FakeFlow,
- )
-
- result = export_signoff_package_archive("workspace", str(output_path))
-
- assert result == str(output_path.resolve())
- import tarfile
-
- with tarfile.open(output_path, "r:gz") as tar:
- assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"archive"
- assert captured_output_dirs
- assert not Path(captured_output_dirs[0]).exists()
- assert not list(output_path.parent.glob(f".{output_path.name}.*"))
-
-
-@pytest.mark.parametrize("archive_path", ["/tmp/ecc-signoff-escape", "../escape.txt"])
-def test_export_signoff_package_archive_rejects_additional_file_path_escape(
- monkeypatch, tmp_path, archive_path
-):
- output_path = tmp_path / "chosen.tar.gz"
-
- class FakeFlow:
- def __init__(self, workspace):
- pass
-
- def collect_signoff_package(self, options):
- package_dir = Path(options.output_dir) / "design_signoff_package"
- package_dir.mkdir(parents=True, exist_ok=True)
- return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[])
-
- monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow)
-
- with pytest.raises(RuntimeApiError) as exc_info:
- signoff_export.export_signoff_package_archive(
- "workspace",
- str(output_path),
- [{"archivePath": archive_path, "content": "must stay inside"}],
- )
-
- assert exc_info.value.code == "invalid_request"
-
-
-def test_export_signoff_package_archive_allows_nested_additional_file(monkeypatch, tmp_path):
- output_path = tmp_path / "chosen.tar.gz"
-
- class FakeFlow:
- def __init__(self, workspace):
- pass
-
- def collect_signoff_package(self, options):
- package_dir = Path(options.output_dir) / "design_signoff_package"
- package_dir.mkdir(parents=True, exist_ok=True)
- return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[])
-
- monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow)
-
- signoff_export.export_signoff_package_archive(
- "workspace",
- str(output_path),
- [{"archivePath": "metadata/extra.txt", "content": "included"}],
- )
-
- import tarfile
-
- with tarfile.open(output_path, "r:gz") as tar:
- assert tar.extractfile("design_signoff_package/metadata/extra.txt").read() == b"included"
-
-
-def test_export_signoff_package_archive_preserves_existing_target_on_incomplete_result(
- monkeypatch,
- tmp_path,
-):
- from chipcompiler.runtime.signoff_export import export_signoff_package_archive
-
- output_path = tmp_path / "existing.tar.gz"
- output_path.write_bytes(b"old")
-
- class FakeFlow:
- def __init__(self, workspace):
- pass
-
- def collect_signoff_package(self, options):
- return SimpleNamespace(
- ok=False,
- package_dir=None,
- missing_required=["harden/design.gds", "harden/design.lef"],
- )
-
- monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.EngineFlow",
- FakeFlow,
- )
-
- with pytest.raises(RuntimeApiError) as exc_info:
- export_signoff_package_archive("workspace", str(output_path))
-
- assert exc_info.value.code == "command_failed"
- assert "harden/design.gds" in exc_info.value.message
- assert "harden/design.lef" in exc_info.value.message
- assert output_path.read_bytes() == b"old"
-
-
-def test_export_signoff_package_archive_replaces_symlink_entry_not_target(
- monkeypatch,
- tmp_path,
-):
- from chipcompiler.runtime.signoff_export import export_signoff_package_archive
-
- target = tmp_path / "target.tar.gz"
- target.write_bytes(b"target")
- output_path = tmp_path / "chosen.tar.gz"
- try:
- output_path.symlink_to(target.name)
- except (OSError, NotImplementedError):
- pytest.skip("symlinks are unavailable")
-
- class FakeFlow:
- def __init__(self, workspace):
- pass
-
- def collect_signoff_package(self, options):
- package_dir = Path(options.output_dir) / "design_signoff_package"
- package_dir.mkdir(parents=True, exist_ok=True)
- (package_dir / "dummy.txt").write_text("new")
- return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[])
-
- monkeypatch.setattr(
- "chipcompiler.runtime.signoff_export.EngineFlow",
- FakeFlow,
- )
-
- export_signoff_package_archive("workspace", str(output_path))
-
- assert not output_path.is_symlink()
- import tarfile
-
- with tarfile.open(output_path, "r:gz") as tar:
- assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"new"
- assert target.read_bytes() == b"target"
diff --git a/test/runtime/test_stdio_server.py b/test/runtime/test_stdio_server.py
deleted file mode 100644
index 7918e4af..00000000
--- a/test/runtime/test_stdio_server.py
+++ /dev/null
@@ -1,241 +0,0 @@
-import io
-import json
-import os
-import select
-import subprocess
-import sys
-from pathlib import Path
-
-from chipcompiler.data import create_workspace
-from chipcompiler.runtime.server import RuntimeServer
-from chipcompiler.runtime.stdio_server import run_stdio_server
-from chipcompiler.runtime.transport import ContentLengthDecoder, encode_content_length_frame
-
-
-def _request(method: str, request_id, params: dict | None = None) -> bytes:
- payload = {"jsonrpc": "2.0", "method": method, "id": request_id}
- if params is not None:
- payload["params"] = params
- return encode_content_length_frame(json.dumps(payload, separators=(",", ":")))
-
-
-def _notification(method: str, params: dict | None = None) -> bytes:
- payload = {"jsonrpc": "2.0", "method": method}
- if params is not None:
- payload["params"] = params
- return encode_content_length_frame(json.dumps(payload, separators=(",", ":")))
-
-
-def _decode_output(output: bytes) -> list[dict]:
- decoder = ContentLengthDecoder()
- return [json.loads(message) for message in decoder.feed(output)]
-
-
-def _read_subprocess_response(process: subprocess.Popen) -> dict:
- assert process.stdout is not None
- assert select.select([process.stdout], [], [], 5)[0]
- length_line = process.stdout.readline()
- assert length_line.startswith(b"Content-Length: ")
- blank_line = process.stdout.readline()
- assert blank_line == b"\r\n"
- length = int(length_line.removeprefix(b"Content-Length: ").strip())
- payload = process.stdout.read(length)
- return json.loads(payload)
-
-
-def _write_subprocess_request(process: subprocess.Popen, method: str, request_id, params=None):
- assert process.stdin is not None
- process.stdin.write(_request(method, request_id, params))
- process.stdin.flush()
-
-
-def _create_real_workspace(tmp_path: Path, minimal_ics55_pdk_factory) -> Path:
- pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55")
- rtl_path = tmp_path / "gcd.v"
- rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n")
- workspace_dir = tmp_path / "workspace"
- create_workspace(
- directory=workspace_dir,
- origin_def="",
- origin_verilog=rtl_path,
- pdk="ics55",
- pdk_root=pdk_root,
- parameters={
- "pdk": "ics55",
- "design": "gcd",
- "top_module": "gcd",
- "clock": "clk",
- "frequency_max": 100,
- },
- )
- return workspace_dir
-
-
-def test_stdio_server_writes_only_content_length_framed_responses():
- stdin = io.BytesIO(
- _request("rpc.hello", 1, {"version": 1})
- + _request("rpc.ping", 2)
- + _request("rpc.shutdown", 3)
- )
- stdout = io.BytesIO()
-
- rc = run_stdio_server(stdin, stdout, server=RuntimeServer())
-
- raw = stdout.getvalue()
- assert rc == 0
- assert raw.startswith(b"Content-Length: ")
- assert raw.count(b"Content-Length: ") == 3
- responses = _decode_output(raw)
- assert [response["id"] for response in responses] == [1, 2, 3]
- assert responses[0]["result"]["version"] == 1
- assert responses[1]["result"] == {"ok": True}
- assert responses[2]["result"] == {"ok": True}
-
-
-def test_stdio_server_does_not_write_response_for_notification():
- stdin = io.BytesIO(_notification("rpc.ping"))
- stdout = io.BytesIO()
-
- rc = run_stdio_server(stdin, stdout, server=RuntimeServer())
-
- assert rc == 0
- assert stdout.getvalue() == b""
-
-
-def test_stdio_server_stops_after_shutdown_notification_in_buffer():
- stdin = io.BytesIO(_notification("rpc.shutdown") + _request("rpc.ping", 1))
- stdout = io.BytesIO()
-
- rc = run_stdio_server(stdin, stdout, server=RuntimeServer())
-
- assert rc == 0
- assert stdout.getvalue() == b""
-
-
-def test_stdio_server_redirects_print_noise_away_from_protocol_stdout(capfd):
- server = RuntimeServer()
- server.dispatcher.add_method("test.noisyPrint", lambda: print("tool output") or {"ok": True})
- stdin = io.BytesIO(_request("test.noisyPrint", 1))
- stdout = io.BytesIO()
-
- rc = run_stdio_server(stdin, stdout, server=server)
-
- captured = capfd.readouterr()
- assert rc == 0
- assert captured.out == ""
- assert "tool output" in captured.err
- assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True}
-
-
-def test_stdio_server_redirects_fd_stdout_noise_away_from_protocol_stdout(capfd):
- server = RuntimeServer()
-
- def noisy_fd():
- os.write(1, b"tool output\n")
- return {"ok": True}
-
- server.dispatcher.add_method("test.noisyFd", noisy_fd)
- stdin = io.BytesIO(_request("test.noisyFd", 1))
- stdout = io.BytesIO()
-
- rc = run_stdio_server(stdin, stdout, server=server)
-
- captured = capfd.readouterr()
- assert rc == 0
- assert captured.out == ""
- assert "tool output" in captured.err
- assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True}
-
-
-def test_rpc_stdio_subprocess_smoke():
- stdin = (
- _request("rpc.hello", 1, {"version": 1})
- + _request("rpc.ping", 2)
- + _request(
- "rpc.shutdown",
- 3,
- )
- )
-
- completed = subprocess.run(
- [sys.executable, "-m", "chipcompiler.cli.main", "rpc", "serve", "--stdio"],
- input=stdin,
- cwd=os.getcwd(),
- capture_output=True,
- check=False,
- )
-
- assert completed.returncode == 0
- responses = _decode_output(completed.stdout)
- assert [response["id"] for response in responses] == [1, 2, 3]
- assert responses[1]["result"] == {"ok": True}
-
-
-def test_rpc_stdio_subprocess_persistent_db_smoke():
- stdin = _request("rpc.hello", 1, {"version": 1}) + _request("rpc.shutdown", 2)
-
- completed = subprocess.run(
- [
- sys.executable,
- "-m",
- "chipcompiler.cli.main",
- "rpc",
- "serve",
- "--stdio",
- "--persistent-db",
- ],
- input=stdin,
- cwd=os.getcwd(),
- capture_output=True,
- check=False,
- )
-
- assert completed.returncode == 0
- responses = _decode_output(completed.stdout)
- assert [response["id"] for response in responses] == [1, 2]
- assert "db.ensure" in responses[0]["result"]["capabilities"]
- assert "db.release" in responses[0]["result"]["capabilities"]
-
-
-def test_rpc_stdio_subprocess_workspace_open_home_smoke(tmp_path, minimal_ics55_pdk_factory):
- ws = _create_real_workspace(tmp_path, minimal_ics55_pdk_factory)
- process = subprocess.Popen(
- [sys.executable, "-m", "chipcompiler.cli.main", "rpc", "serve", "--stdio"],
- cwd=os.getcwd(),
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
-
- try:
- _write_subprocess_request(process, "workspace.open", 1, {"directory": str(ws)})
- open_response = _read_subprocess_response(process)
- workspace_id = open_response["result"]["workspaceId"]
-
- _write_subprocess_request(
- process,
- "workspace.home",
- 2,
- {"workspaceId": workspace_id},
- )
- home_response = _read_subprocess_response(process)
-
- _write_subprocess_request(process, "rpc.shutdown", 3)
- shutdown_response = _read_subprocess_response(process)
- 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 open_response["result"] == {
- "workspaceId": workspace_id,
- "directory": str(ws.resolve()),
- }
- assert home_response == {
- "jsonrpc": "2.0",
- "result": {"path": str(ws.resolve() / "home" / "home.json")},
- "id": 2,
- }
- assert shutdown_response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3}
diff --git a/test/runtime/test_transport.py b/test/runtime/test_transport.py
deleted file mode 100644
index 9b88db82..00000000
--- a/test/runtime/test_transport.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import re
-from pathlib import Path
-
-import pytest
-
-from chipcompiler.runtime.transport import (
- ContentLengthDecoder,
- TransportError,
- encode_content_length_frame,
-)
-
-
-def test_encodes_and_decodes_one_content_length_frame():
- frame = encode_content_length_frame(b'{"jsonrpc":"2.0","id":1}')
-
- assert frame.startswith(b"Content-Length: 24\r\n\r\n")
- decoder = ContentLengthDecoder()
- assert decoder.feed(frame) == [b'{"jsonrpc":"2.0","id":1}']
-
-
-def test_decodes_multiple_frames_from_one_buffer():
- frame = encode_content_length_frame(b'{"id":1}') + encode_content_length_frame(b'{"id":2}')
-
- decoder = ContentLengthDecoder()
-
- assert decoder.feed(frame) == [b'{"id":1}', b'{"id":2}']
-
-
-def test_buffers_partial_header_and_payload_until_complete():
- frame = encode_content_length_frame(b'{"id":1}')
- decoder = ContentLengthDecoder()
-
- assert decoder.feed(frame[:5]) == []
- assert decoder.feed(frame[5:20]) == []
- assert decoder.feed(frame[20:-1]) == []
- assert decoder.feed(frame[-1:]) == [b'{"id":1}']
-
-
-def test_malformed_content_length_header_is_transport_error():
- decoder = ContentLengthDecoder()
-
- with pytest.raises(TransportError, match="Content-Length"):
- decoder.feed(b"Content-Length: nope\r\n\r\n{}")
-
-
-def test_missing_content_length_header_is_transport_error():
- decoder = ContentLengthDecoder()
-
- with pytest.raises(TransportError, match="Content-Length"):
- decoder.feed(b"X-Length: 2\r\n\r\n{}")
-
-
-def test_oversize_payload_is_transport_error():
- decoder = ContentLengthDecoder(max_payload_size=4)
-
- with pytest.raises(TransportError, match="exceeds"):
- decoder.feed(encode_content_length_frame(b"12345"))
-
-
-def test_workspace_rpc_doc_content_lengths_match_payloads():
- source = Path("docs/rpc-guide.md").read_text(encoding="utf-8")
- frames = re.findall(r"Content-Length: (\d+)\n\n({\"jsonrpc\"[^\n]+})", source)
-
- assert frames
- for declared, payload in frames:
- assert int(declared) == len(payload.encode("utf-8"))
-
-
-def test_workspace_rpc_docs_cover_opt_in_persistent_db_surface():
- source = Path("docs/rpc-guide.md").read_text(encoding="utf-8")
- cli_design = Path("docs/specification/cli-design.md").read_text(encoding="utf-8")
-
- for text in (source, cli_design):
- assert "--persistent-db" in text
- assert "db.ensure" in text
- assert "db.release" in text
- assert "default sidecar does not advertise or persist native DB handles" in cli_design
- assert (
- "Opening or creating a workspace does not initialize persistent native DB state" in source
- )
diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py
deleted file mode 100644
index be63e65d..00000000
--- a/test/runtime/test_workspace_api.py
+++ /dev/null
@@ -1,1616 +0,0 @@
-import json
-import queue
-import threading
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-
-from chipcompiler.data import StateEnum
-from chipcompiler.runtime.requests import (
- DbEnsureRequest,
- DbReleaseRequest,
- FlowRunRequest,
- FlowRunStepRequest,
- OperationStartFlowRequest,
- WorkspaceCreateRequest,
- WorkspaceIdRequest,
- WorkspaceInfoRequest,
- WorkspaceOpenRequest,
- WorkspaceRecoverInterruptedRequest,
- WorkspaceSyncConfigRequest,
-)
-from chipcompiler.runtime.sessions import WorkspaceSessionRegistry
-from chipcompiler.runtime.workspace_api import RuntimeApiError, WorkspaceRuntimeApi
-
-
-class DummyEngineDB:
- def __init__(self, flow):
- self.flow = flow
- self.initialized = False
- self.close_calls = 0
-
- def has_init(self):
- return self.initialized
-
- def create_db_engine(self, step):
- self.flow.init_db_engine_calls += 1
- self.flow.init_db_engine_steps.append(None if step is None else step.name)
- self.flow.init_db_engine_inputs.append(
- (
- None if step is None else getattr(step, "input_def", ""),
- None if step is None else getattr(step, "input_verilog", ""),
- )
- )
- self.flow.call_order.append(("init_db_engine",))
- self.initialized = self.flow.next_init_success
- return self.initialized
-
- def close(self):
- if not self.initialized:
- return
- self.close_calls += 1
- self.initialized = False
-
-
-class DummyFlow:
- instances = []
- next_run_states = []
- next_init_success = True
- successful_steps = set()
- workspace_step_specs = None
-
- def __init__(self, workspace):
- self.workspace = workspace
- self.added_steps = []
- self.created = False
- self.prepared_for_rerun = False
- self.run_steps_calls = []
- self.run_calls = []
- self.flow_init_db_engine_calls = 0
- self.init_db_engine_calls = 0
- self.init_db_engine_steps = []
- self.init_db_engine_inputs = []
- self.call_order = []
- specs = self.workspace_step_specs or (
- {"name": "Synthesis", "tool": "yosys"},
- {"name": "Floorplan", "tool": "ecc"},
- )
- self.workspace_steps = [SimpleNamespace(**spec) for spec in specs]
- self.completed_steps = set()
- self.engine_db = DummyEngineDB(self)
- DummyFlow.instances.append(self)
-
- def has_init(self):
- return False
-
- def add_step(self, step, tool, state):
- self.added_steps.append((step, tool, state))
- self.workspace.flow.data.setdefault("steps", []).append(
- {"name": step, "tool": tool, "state": state}
- )
-
- def create_step_workspaces(self):
- self.created = True
-
- def run_steps(self, *, rerun=False):
- self.run_steps_calls.append(rerun)
- success = True
- for workspace_step in self.workspace_steps:
- self.init_db_engine()
- state = self.run_step(workspace_step, rerun=rerun)
- if state != StateEnum.Success:
- success = False
- break
- return success
-
- def init_db_engine(self):
- self.flow_init_db_engine_calls += 1
- self.call_order.append(("flow_init_db_engine",))
- if self.engine_db is None:
- self.engine_db = DummyEngineDB(self)
- workspace_step = self.workspace_steps[0]
- for candidate in self.workspace_steps:
- if candidate.name not in self.completed_steps:
- workspace_step = candidate
- break
- return self.engine_db.create_db_engine(workspace_step)
-
- def run_step(self, workspace_step, *, rerun=False):
- name = workspace_step if isinstance(workspace_step, str) else workspace_step.name
- self.run_calls.append((name, rerun))
- self.call_order.append(("run_step", name, rerun))
- state = DummyFlow.next_run_states.pop(0) if DummyFlow.next_run_states else StateEnum.Success
- if state == StateEnum.Success:
- self.completed_steps.add(name)
- workspace_step_object = self.get_workspace_step(name)
- if getattr(workspace_step_object, "tool", "") == "sizer":
- if self.engine_db is not None:
- self.engine_db.close()
- self.engine_db = None
- return state
-
- def get_workspace_step(self, name):
- for step in self.workspace_steps:
- if step.name == name:
- return step
- return 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 step
- return None
-
- def save(self):
- return True
-
- def check_state(self, name, tool, state):
- return getattr(state, "value", state) == StateEnum.Success.value and name in (
- self.successful_steps
- )
-
-
-def _workspace(directory: Path):
- design = SimpleNamespace(
- name="gcd",
- top_module="gcd",
- origin_def="",
- origin_verilog=directory / "origin" / "gcd.v",
- input_filelist="",
- )
- return SimpleNamespace(
- directory=directory.resolve(),
- design=design,
- flow=SimpleNamespace(path=directory / "home" / "flow.json", data={"steps": []}),
- home=SimpleNamespace(path=directory / "home" / "home.json"),
- )
-
-
-def _install_runtime_mocks(monkeypatch, tmp_path, *, create_workspace_files=True):
- capture = {
- "create_kwargs": None,
- "input_filelist_lines": [],
- "loaded": [],
- "workspace_entries_when_create_called": [],
- }
- DummyFlow.instances = []
- DummyFlow.next_run_states = []
- DummyFlow.next_init_success = True
- DummyFlow.successful_steps = set()
- DummyFlow.workspace_step_specs = None
-
- def fake_create_workspace(**kwargs):
- capture["create_kwargs"] = kwargs
- input_filelist = kwargs.get("input_filelist")
- if input_filelist and Path(input_filelist).exists():
- capture["input_filelist_lines"] = (
- Path(input_filelist).read_text(encoding="utf-8").splitlines()
- )
- workspace_dir = Path(kwargs["directory"])
- if workspace_dir.is_dir():
- capture["workspace_entries_when_create_called"] = sorted(
- path.name for path in workspace_dir.iterdir()
- )
- return _workspace(Path(kwargs["directory"]))
-
- def fake_load_workspace(directory):
- capture["loaded"].append(directory)
- return _workspace(Path(directory))
-
- monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace)
- monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace)
- monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", lambda workspace: None)
- monkeypatch.setattr(
- "chipcompiler.data.prepare_workspace_for_rerun", lambda ws, flow, **_kwargs: None
- )
- monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow)
- monkeypatch.setattr(
- "chipcompiler.rtl2gds.build_rtl2gds_flow",
- lambda: [("Synthesis", "yosys", "Unstart")],
- )
-
- ws = tmp_path / "workspace"
- if create_workspace_files:
- (ws / "home").mkdir(parents=True)
- (ws / "home" / "parameters.json").write_text("{}")
- (ws / "home" / "flow.json").write_text(json.dumps({"steps": []}))
- (ws / "home" / "home.json").write_text("{}")
- return capture, ws
-
-
-def _assert_call_waits_for_session_lock(api, workspace_id, call, entered):
- session = api.sessions.get_session(workspace_id)
- result_queue = queue.Queue()
-
- def run_call():
- try:
- result_queue.put(("result", call()))
- except BaseException as exc: # pragma: no cover - re-raised in test thread
- result_queue.put(("error", exc))
-
- with session.mutation_lock:
- worker = threading.Thread(target=run_call)
- worker.start()
- assert not entered.wait(0.1)
- assert worker.is_alive()
-
- worker.join(timeout=2)
- assert not worker.is_alive()
- kind, payload = result_queue.get_nowait()
- if kind == "error":
- raise payload
- assert entered.is_set()
- return payload
-
-
-def test_create_workspace_returns_plain_runtime_result_and_session(monkeypatch, tmp_path):
- capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
-
- result = api.create_workspace(
- WorkspaceCreateRequest(
- directory=str(ws),
- pdk="ics55",
- pdk_root="/pdk",
- pdk_json={"name": "ics55"},
- parameters={"design": "gcd"},
- rtl_list=["a.v"],
- sdc="/constraints/top.sdc",
- )
- )
-
- assert set(result) == {"workspaceId", "directory"}
- assert result["directory"] == str(ws.resolve())
- assert result["workspaceId"].startswith("workspace-")
- assert isinstance(capture["create_kwargs"]["pdk_json"], str)
- assert capture["create_kwargs"]["sdc"] == "/constraints/top.sdc"
- assert DummyFlow.instances[0].created
- assert api.sessions.get_session(result["workspaceId"]).directory == ws.resolve()
-
-
-def test_create_workspace_forwards_dynamic_flow_config(monkeypatch, tmp_path):
- capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- flow_config = {
- "start_step": "Synthesis",
- "end_step": "Harden",
- "steps": ["Synthesis", "RCX", "sta", "Harden"],
- }
-
- WorkspaceRuntimeApi().create_workspace(
- WorkspaceCreateRequest(directory=str(ws), flow_config=flow_config)
- )
-
- assert capture["create_kwargs"]["flow_config"] == flow_config
-
-
-def test_create_workspace_writes_rtl_list_filelist_outside_workspace(
- monkeypatch,
- tmp_path,
-):
- capture, ws = _install_runtime_mocks(
- monkeypatch,
- tmp_path,
- create_workspace_files=False,
- )
- project = tmp_path / "project"
- project.mkdir()
- rtl_paths = [str(project / "a.v"), str(project / "b.v")]
- api = WorkspaceRuntimeApi()
-
- api.create_workspace(
- WorkspaceCreateRequest(
- directory=str(ws),
- pdk="ics55",
- parameters={"design": "gcd"},
- rtl_list=rtl_paths,
- )
- )
-
- input_filelist = Path(capture["create_kwargs"]["input_filelist"])
- assert input_filelist.name == "filelist"
- assert not input_filelist.is_relative_to(ws)
- assert capture["input_filelist_lines"] == rtl_paths
- assert capture["workspace_entries_when_create_called"] == []
- assert not (ws / "filelist").exists()
-
-
-def test_create_workspace_materializes_inline_pdk_json_before_data_api(monkeypatch, tmp_path):
- pdk_json = {"name": "ics55", "lef": ["tech.lef"]}
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- seen = {}
-
- def create_workspace(**kwargs):
- pdk_json_path = Path(kwargs["pdk_json"])
- seen["pdk_json"] = json.loads(pdk_json_path.read_text(encoding="utf-8"))
- return _workspace(Path(kwargs["directory"]))
-
- monkeypatch.setattr("chipcompiler.data.create_workspace", create_workspace)
- api = WorkspaceRuntimeApi()
-
- api.create_workspace(
- WorkspaceCreateRequest(
- directory=str(ws),
- pdk="ics55",
- pdk_json=pdk_json,
- )
- )
-
- assert seen["pdk_json"] == pdk_json
-
-
-def test_create_workspace_with_inline_pdk_json_uses_real_data_api(monkeypatch, tmp_path):
- pdk_root = tmp_path / "pdk"
- tech = pdk_root / "tech.lef"
- lef = pdk_root / "stdcell.lef"
- liberty = pdk_root / "stdcell.lib"
- for path in (tech, lef, liberty):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("VERSION 5.8 ;\n")
-
- workspace_dir = tmp_path / "workspace"
- monkeypatch.setattr(
- "chipcompiler.runtime.workspace_api.build_flow_for_workspace",
- lambda _workspace: SimpleNamespace(),
- )
-
- api = WorkspaceRuntimeApi()
- result = api.create_workspace(
- WorkspaceCreateRequest(
- directory=str(workspace_dir),
- pdk="ics55",
- pdk_json={
- "name": "ics55",
- "root": str(pdk_root),
- "tech": str(tech),
- "lefs": [str(lef)],
- "libs": [str(liberty)],
- },
- parameters={
- "design": "gcd",
- "top_module": "gcd",
- "clock": "clk",
- },
- )
- )
-
- assert result["directory"] == str(workspace_dir.resolve())
- pdk_config_path = workspace_dir / "home" / "pdk.json"
- assert pdk_config_path.is_file()
- from chipcompiler.data.parameter import load_parameter
-
- parameters = load_parameter(workspace_dir / "home" / "params.toml").data
- assert parameters["pdk_config"] == str(pdk_config_path.resolve())
- session = api.sessions.get_session(result["workspaceId"])
- assert session.workspace.pdk.tech == tech
- assert session.workspace.pdk.lefs == [lef]
- assert session.workspace.pdk.libs == [liberty]
- assert session.workspace.pdk.buffers
- assert session.directory == workspace_dir.resolve()
-
-
-def test_open_workspace_loads_without_creating_step_workspaces(monkeypatch, tmp_path):
- capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
-
- result = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))
-
- assert result == {
- "workspaceId": result["workspaceId"],
- "directory": str(ws.resolve()),
- }
- assert capture["loaded"] == [str(ws)]
- assert not DummyFlow.instances[0].created
-
-
-def test_recover_interrupted_is_marker_scoped_and_idempotent(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- session = api.sessions.get_session(workspace_id)
- session.workspace.flow.data = {
- "steps": [
- {
- "name": "place",
- "tool": "dreamplace",
- "state": "Ongoing",
- "info": {
- "runtime_operation": {
- "schema": 1,
- "operation_id": "operation-1",
- "runtime_instance_id": "runtime-old",
- "started_at": 1.0,
- }
- },
- },
- {"name": "route", "tool": "ecc", "state": "Ongoing", "info": {}},
- {
- "name": "Floorplan",
- "tool": "ecc",
- "state": "Success",
- "info": {
- "runtime_operation": {
- "schema": 1,
- "operation_id": "operation-2",
- }
- },
- },
- {
- "name": "CTS",
- "tool": "ecc",
- "state": "Ongoing",
- "info": {
- "runtime_operation": {
- "schema": 1,
- "operation_id": "operation-partial",
- }
- },
- },
- {
- "name": "STA",
- "tool": "ecc",
- "state": "Ongoing",
- "info": {
- "runtime_operation": {
- "schema": 1,
- "operation_id": "operation-active",
- "runtime_instance_id": "runtime-current",
- "started_at": 2.0,
- }
- },
- },
- {
- "name": "DRC",
- "tool": "ecc",
- "state": "Ongoing",
- "info": {
- "runtime_operation": {
- "schema": 1,
- "operation_id": "operation-previous",
- "runtime_instance_id": "runtime-old",
- "started_at": 3.0,
- }
- },
- },
- ]
- }
- mismatch = api.recover_interrupted(
- WorkspaceRecoverInterruptedRequest(workspace_id, "operation-other")
- )
- assert mismatch == {"recovered": []}
-
- result = api.recover_interrupted(
- WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1")
- )
- assert result == {
- "recovered": [
- {
- "step": "place",
- "tool": "dreamplace",
- "operationId": "operation-1",
- "logFile": str(ws / "place_dreamplace" / "log" / "place.log"),
- }
- ]
- }
- assert session.workspace.flow.data["steps"][0]["state"] == StateEnum.Imcomplete.value
- assert session.workspace.flow.data["steps"][0]["info"] == {}
- assert session.workspace.flow.data["steps"][1]["state"] == "Ongoing"
- assert session.workspace.flow.data["steps"][2]["state"] == "Success"
- monkeypatch.setattr(
- api.operations,
- "is_active",
- lambda operation_id: operation_id == "operation-active",
- )
- previous = api.recover_interrupted(WorkspaceRecoverInterruptedRequest(workspace_id))
- assert previous == {
- "recovered": [
- {
- "step": "DRC",
- "tool": "ecc",
- "operationId": "operation-previous",
- "logFile": str(ws / "DRC_ecc" / "log" / "DRC.log"),
- }
- ]
- }
- assert session.workspace.flow.data["steps"][3]["state"] == "Ongoing"
- assert session.workspace.flow.data["steps"][4]["state"] == "Ongoing"
- assert session.workspace.flow.data["steps"][5]["state"] == StateEnum.Imcomplete.value
- assert api.recover_interrupted(
- WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1")
- ) == {"recovered": []}
-
-
-def test_create_workspace_replaces_existing_same_directory_session(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
-
- opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))
- opened_session = api.sessions.get_session(opened["workspaceId"])
- opened_session.db_handle = object()
- created = api.create_workspace(WorkspaceCreateRequest(directory=str(ws)))
-
- assert created["workspaceId"] != opened["workspaceId"]
- assert opened_session.db_handle is None
- with pytest.raises(RuntimeApiError, match="workspace session not found"):
- api.workspace_home(WorkspaceIdRequest(workspace_id=opened["workspaceId"]))
- created_session = api.sessions.get_session(created["workspaceId"])
- assert created_session.workspace is not opened_session.workspace
-
-
-def test_open_workspace_reuses_existing_same_directory_session(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
-
- first = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))
- first_session = api.sessions.get_session(first["workspaceId"])
- second = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))
-
- assert second["workspaceId"] == first["workspaceId"]
- assert api.sessions.get_session(second["workspaceId"]).workspace is first_session.workspace
-
-
-def test_workspace_home_and_info_use_session_id(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- monkeypatch.setattr(
- "chipcompiler.tools.get_step_info",
- lambda workspace, step, id: {"path": Path(workspace.directory) / "layout.png"},
- )
- api = WorkspaceRuntimeApi()
- opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))
- workspace_id = opened["workspaceId"]
-
- home = api.workspace_home(WorkspaceIdRequest(workspace_id=workspace_id))
- info = api.workspace_info(
- WorkspaceInfoRequest(workspace_id=workspace_id, step="Synthesis", info_id="layout")
- )
-
- assert home == {"path": str(ws.resolve() / "home" / "home.json")}
- assert info == {
- "step": "Synthesis",
- "id": "layout",
- "info": {"path": str(ws.resolve() / "layout.png")},
- }
-
-
-def test_refresh_sync_and_reset_flow_use_session(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- refreshed = []
- synced = []
- prepared = []
-
- monkeypatch.setattr(
- "chipcompiler.data.refresh_workspace_config",
- lambda workspace: refreshed.append(workspace.directory),
- )
- monkeypatch.setattr(
- "chipcompiler.data.sync_workspace_config_to_parameters",
- lambda workspace, path: synced.append((workspace.directory, path)) or True,
- )
- monkeypatch.setattr(
- "chipcompiler.data.prepare_workspace_for_rerun",
- lambda workspace, flow, **_kwargs: prepared.append((workspace.directory, flow)),
- )
- config_dir = ws / "config"
- config_dir.mkdir()
- config_path = config_dir / "route.json"
- config_path.write_text("{}")
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- refresh = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id))
- sync = api.sync_config(
- WorkspaceSyncConfigRequest(
- workspace_id=workspace_id,
- config_path=str(config_path),
- )
- )
- reset = api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id))
-
- assert refresh == {"directory": str(ws.resolve()), "refreshed": True}
- assert sync == {
- "directory": str(ws.resolve()),
- "configPath": str(config_path.resolve()),
- "parametersChanged": True,
- "refreshed": True,
- }
- assert reset == {"directory": str(ws.resolve())}
- assert refreshed == [ws.resolve(), ws.resolve()]
- assert synced == [(ws.resolve(), config_path.resolve())]
- assert prepared == [(ws.resolve(), DummyFlow.instances[-1])]
-
-
-def test_refresh_config_releases_active_session_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id))
-
- assert result == {"directory": str(ws.resolve()), "refreshed": True}
- assert db_handle.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_sync_config_releases_active_session_db_only_when_parameters_change(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- config_dir = ws / "config"
- config_dir.mkdir()
- config_path = config_dir / "route.json"
- config_path.write_text("{}")
- changed = [False, True]
-
- monkeypatch.setattr(
- "chipcompiler.data.sync_workspace_config_to_parameters",
- lambda _workspace, _path: changed.pop(0),
- )
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- unchanged = api.sync_config(
- WorkspaceSyncConfigRequest(workspace_id=workspace_id, config_path=str(config_path))
- )
- assert unchanged["parametersChanged"] is False
- assert unchanged["refreshed"] is False
- assert db_handle.close_calls == 0
-
- changed_result = api.sync_config(
- WorkspaceSyncConfigRequest(workspace_id=workspace_id, config_path=str(config_path))
- )
-
- assert changed_result["parametersChanged"] is True
- assert changed_result["refreshed"] is True
- assert db_handle.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_reset_flow_releases_active_session_db_before_prepare(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- prepared = []
-
- def prepare(workspace, flow, **_kwargs):
- prepared.append((workspace.directory, flow))
- assert api.sessions.get_session(workspace_id).db_handle is None
-
- monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id))
-
- assert result == {"directory": str(ws.resolve())}
- assert db_handle.close_calls == 1
- assert prepared == [(ws.resolve(), DummyFlow.instances[-1])]
-
-
-def test_refresh_config_waits_for_session_mutation_lock(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- entered = threading.Event()
-
- def refresh_config(_workspace):
- entered.set()
-
- monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config)
-
- _assert_call_waits_for_session_lock(
- api=api,
- workspace_id=workspace_id,
- call=lambda: api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)),
- entered=entered,
- )
-
-
-def test_sync_config_waits_for_session_mutation_lock(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- config_dir = ws / "config"
- config_dir.mkdir()
- config_path = config_dir / "route.json"
- config_path.write_text("{}")
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- entered = threading.Event()
-
- def sync_config(_workspace, _path):
- entered.set()
- return False
-
- monkeypatch.setattr("chipcompiler.data.sync_workspace_config_to_parameters", sync_config)
-
- _assert_call_waits_for_session_lock(
- api=api,
- workspace_id=workspace_id,
- call=lambda: api.sync_config(
- WorkspaceSyncConfigRequest(
- workspace_id=workspace_id,
- config_path=str(config_path),
- )
- ),
- entered=entered,
- )
-
-
-def test_reset_flow_waits_for_session_mutation_lock(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- entered = threading.Event()
-
- def build_flow(_workspace):
- entered.set()
- return SimpleNamespace()
-
- monkeypatch.setattr("chipcompiler.runtime.workspace_api.build_flow_for_workspace", build_flow)
- monkeypatch.setattr(
- "chipcompiler.data.prepare_workspace_for_rerun", lambda _ws, _flow, **_kwargs: None
- )
-
- _assert_call_waits_for_session_lock(
- api=api,
- workspace_id=workspace_id,
- call=lambda: api.reset_flow(WorkspaceIdRequest(workspace_id=workspace_id)),
- entered=entered,
- )
-
-
-def test_unknown_session_returns_structured_runtime_error():
- api = WorkspaceRuntimeApi()
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.workspace_home(WorkspaceIdRequest(workspace_id="missing"))
-
- assert exc_info.value.code == "workspace_session_not_found"
-
-
-def test_db_ensure_rejects_disabled_runtime_api(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id))
-
- assert exc_info.value.code == "command_failed"
- assert exc_info.value.message == "persistent_db_disabled"
-
-
-def test_db_ensure_initializes_requested_step_and_stores_session_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
-
- flow = DummyFlow.instances[-1]
- session = api.sessions.get_session(workspace_id)
- assert result == {
- "workspaceId": workspace_id,
- "enabled": True,
- "active": True,
- "reused": False,
- "step": "Floorplan",
- }
- assert flow.init_db_engine_steps == ["Floorplan"]
- assert session.db_handle is flow.engine_db
- assert session.db_handle.has_init()
-
-
-def test_db_ensure_without_step_uses_flow_selection_rule(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id))
-
- flow = DummyFlow.instances[-1]
- assert result == {
- "workspaceId": workspace_id,
- "enabled": True,
- "active": True,
- "reused": False,
- "step": "",
- }
- assert flow.flow_init_db_engine_calls == 1
- assert flow.init_db_engine_steps == ["Synthesis"]
- assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db
-
-
-def test_db_ensure_reuses_initialized_session_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- first = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- second = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
-
- assert first["reused"] is False
- assert second == {
- "workspaceId": workspace_id,
- "enabled": True,
- "active": True,
- "reused": True,
- "step": "Floorplan",
- }
- assert api.sessions.get_session(workspace_id).db_handle is db_handle
-
-
-def test_db_ensure_unknown_step_returns_runtime_error(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Missing"))
-
- assert exc_info.value.code == "command_failed"
- assert exc_info.value.message == "step not found: Missing"
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_db_ensure_does_not_store_uninitialized_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- DummyFlow.next_init_success = False
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
-
- assert result == {
- "workspaceId": workspace_id,
- "enabled": True,
- "active": False,
- "reused": False,
- "step": "Floorplan",
- }
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_db_release_closes_and_clears_active_session_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.db_release(DbReleaseRequest(workspace_id=workspace_id))
-
- assert result == {"workspaceId": workspace_id, "released": True}
- assert db_handle.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_db_release_is_idempotent_when_no_db_is_active(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.db_release(DbReleaseRequest(workspace_id=workspace_id))
-
- assert result == {"workspaceId": workspace_id, "released": False}
-
-
-def test_db_release_closes_db_with_injected_session_registry(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(
- sessions=WorkspaceSessionRegistry(),
- persistent_db_enabled=True,
- )
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.db_release(DbReleaseRequest(workspace_id=workspace_id))
-
- assert result == {"workspaceId": workspace_id, "released": True}
- assert db_handle.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_runtime_modules_do_not_import_typer_or_click():
- for path in Path("chipcompiler/runtime").glob("*.py"):
- source = path.read_text()
- assert "import typer" not in source
- assert "import click" not in source
-
-
-def test_flow_run_uses_run_steps_and_prepare_on_rerun(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- prepared = []
- monkeypatch.setattr(
- "chipcompiler.data.prepare_workspace_for_rerun",
- lambda workspace, flow, **kwargs: prepared.append((workspace.directory, flow, kwargs)),
- )
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True))
-
- flow = DummyFlow.instances[-1]
- assert result == {"rerun": True}
- assert prepared == [(ws.resolve(), flow, {"preserve_user_inputs": False})]
- assert flow.run_steps_calls == [True]
-
-
-def test_gui_flow_operation_rerun_preserves_current_user_inputs(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- captured = {}
-
- def fake_flow_run(request, *, observer=None, preserve_user_inputs=False):
- captured.update(
- request=request,
- observer=observer,
- preserve_user_inputs=preserve_user_inputs,
- )
- return {"rerun": request.rerun}
-
- def fake_start(**kwargs):
- return kwargs["runner"](None)
-
- monkeypatch.setattr(api, "_flow_run", fake_flow_run)
- monkeypatch.setattr(api.operations, "start", fake_start)
-
- result = api.start_flow_operation(
- OperationStartFlowRequest(
- workspace_id=workspace_id,
- origin="gui",
- rerun=True,
- idempotency_key="gui-rerun",
- )
- )
-
- assert result == {"rerun": True}
- assert captured["request"].workspace_id == workspace_id
- assert captured["request"].rerun is True
- assert captured["observer"] is None
- assert captured["preserve_user_inputs"] is True
-
-
-def test_flow_run_without_active_session_db_closes_transient_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False))
-
- flow = DummyFlow.instances[-1]
- assert result == {"rerun": False}
- assert not flow.engine_db.has_init()
- assert flow.engine_db.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_flow_run_with_active_session_db_injects_and_captures_final_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False))
-
- flow = DummyFlow.instances[-1]
- assert result == {"rerun": False}
- assert flow.engine_db is db_handle
- assert api.sessions.get_session(workspace_id).db_handle is db_handle
- assert db_handle.close_calls == 0
-
-
-def test_flow_run_rerun_releases_stale_db_and_captures_new_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- prepared = []
-
- def prepare(workspace, flow, **_kwargs):
- prepared.append((workspace.directory, flow))
- assert api.sessions.get_session(workspace_id).db_handle is None
-
- monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- stale_db = api.sessions.get_session(workspace_id).db_handle
-
- result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True))
-
- flow = DummyFlow.instances[-1]
- assert result == {"rerun": True}
- assert stale_db.close_calls == 1
- assert prepared == [(ws.resolve(), flow)]
- assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db
- assert flow.engine_db is not stale_db
- assert flow.engine_db.has_init()
-
-
-def test_flow_run_sizer_boundary_captures_post_sizer_db(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def")
- sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v")
- DummyFlow.workspace_step_specs = (
- {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"},
- {
- "name": "Timing optimization",
- "tool": "sizer",
- "input_def": "floorplan.def",
- "input_verilog": "floorplan.v",
- "output": {"def": sizer_def, "verilog": sizer_verilog},
- },
- {
- "name": "Legalization",
- "tool": "ecc",
- "input_def": sizer_def,
- "input_verilog": sizer_verilog,
- },
- )
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- pre_sizer_db = api.sessions.get_session(workspace_id).db_handle
-
- result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False))
-
- flow = DummyFlow.instances[-1]
- post_sizer_db = api.sessions.get_session(workspace_id).db_handle
- assert result == {"rerun": False}
- assert pre_sizer_db.close_calls == 1
- assert post_sizer_db is flow.engine_db
- assert post_sizer_db is not pre_sizer_db
- assert post_sizer_db.has_init()
- assert flow.init_db_engine_steps[-1] == "Legalization"
- assert flow.init_db_engine_inputs[-1] == (sizer_def, sizer_verilog)
-
-
-def test_flow_run_sizer_boundary_failure_captures_post_sizer_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def")
- sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v")
- DummyFlow.workspace_step_specs = (
- {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"},
- {
- "name": "Timing optimization",
- "tool": "sizer",
- "input_def": "floorplan.def",
- "input_verilog": "floorplan.v",
- "output": {"def": sizer_def, "verilog": sizer_verilog},
- },
- {
- "name": "Legalization",
- "tool": "ecc",
- "input_def": sizer_def,
- "input_verilog": sizer_verilog,
- },
- )
- DummyFlow.next_run_states = [
- StateEnum.Success,
- StateEnum.Success,
- StateEnum.Imcomplete,
- ]
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- pre_sizer_db = api.sessions.get_session(workspace_id).db_handle
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False))
-
- flow = DummyFlow.instances[-1]
- post_sizer_db = api.sessions.get_session(workspace_id).db_handle
- assert exc_info.value.code == "command_failed"
- assert pre_sizer_db.close_calls == 1
- assert post_sizer_db is flow.engine_db
- assert post_sizer_db is not pre_sizer_db
- assert post_sizer_db.has_init()
- assert flow.init_db_engine_steps[-1] == "Legalization"
-
-
-def test_flow_run_sizer_boundary_exception_captures_post_sizer_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- DummyFlow.workspace_step_specs = (
- {"name": "Floorplan", "tool": "ecc"},
- {"name": "Timing optimization", "tool": "sizer"},
- {"name": "Legalization", "tool": "ecc"},
- )
-
- def run_steps_raises_after_post_sizer_db(self, *, rerun=False):
- del rerun
- self.engine_db.close()
- self.engine_db = DummyEngineDB(self)
- self.engine_db.create_db_engine(self.workspace_steps[-1])
- raise ValueError("post-sizer failure")
-
- monkeypatch.setattr(DummyFlow, "run_steps", run_steps_raises_after_post_sizer_db)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- pre_sizer_db = api.sessions.get_session(workspace_id).db_handle
-
- with pytest.raises(ValueError, match="post-sizer failure"):
- api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False))
-
- flow = DummyFlow.instances[-1]
- post_sizer_db = api.sessions.get_session(workspace_id).db_handle
- assert pre_sizer_db.close_calls == 1
- assert post_sizer_db is flow.engine_db
- assert post_sizer_db is not pre_sizer_db
- assert post_sizer_db.has_init()
-
-
-def test_flow_run_step_initializes_db_before_direct_step(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False)
- )
-
- flow = DummyFlow.instances[-1]
- assert result == {"step": "Synthesis", "state": "Success"}
- assert flow.init_db_engine_steps == ["Synthesis"]
- assert flow.call_order == [
- ("init_db_engine",),
- ("run_step", "Synthesis", False),
- ]
- assert flow.run_steps_calls == []
- assert not flow.engine_db.has_init()
- assert flow.engine_db.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_flow_run_step_with_active_session_db_injects_and_captures_final_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- db_handle = api.sessions.get_session(workspace_id).db_handle
-
- result = api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=False)
- )
-
- flow = DummyFlow.instances[-1]
- assert result == {"step": "Floorplan", "state": "Success"}
- assert flow.engine_db is db_handle
- assert api.sessions.get_session(workspace_id).db_handle is db_handle
- assert db_handle.close_calls == 0
-
-
-def test_flow_run_step_successful_sizer_releases_active_session_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- DummyFlow.workspace_step_specs = (
- {"name": "Floorplan", "tool": "ecc"},
- {"name": "Timing optimization", "tool": "sizer"},
- )
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- pre_sizer_db = api.sessions.get_session(workspace_id).db_handle
-
- result = api.flow_run_step(
- FlowRunStepRequest(
- workspace_id=workspace_id,
- step="Timing optimization",
- rerun=False,
- )
- )
-
- flow = DummyFlow.instances[-1]
- assert result == {"step": "Timing optimization", "state": "Success"}
- assert flow.engine_db is None
- assert pre_sizer_db.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_flow_run_step_sizer_exception_clears_closed_session_db(
- monkeypatch,
- tmp_path,
-):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- DummyFlow.workspace_step_specs = (
- {"name": "Floorplan", "tool": "ecc"},
- {"name": "Timing optimization", "tool": "sizer"},
- )
-
- def run_step_raises_after_sizer_boundary(self, workspace_step, *, rerun=False):
- del workspace_step, rerun
- self.engine_db.close()
- self.engine_db = None
- raise ValueError("sizer failure")
-
- monkeypatch.setattr(DummyFlow, "run_step", run_step_raises_after_sizer_boundary)
- api = WorkspaceRuntimeApi(persistent_db_enabled=True)
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan"))
- pre_sizer_db = api.sessions.get_session(workspace_id).db_handle
-
- with pytest.raises(ValueError, match="sizer failure"):
- api.flow_run_step(
- FlowRunStepRequest(
- workspace_id=workspace_id,
- step="Timing optimization",
- rerun=False,
- )
- )
-
- assert pre_sizer_db.close_calls == 1
- assert api.sessions.get_session(workspace_id).db_handle is None
-
-
-def test_flow_run_step_rerun_refreshes_before_db_init(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- refreshed = []
-
- def refresh_config(workspace):
- refreshed.append(workspace.directory)
- DummyFlow.instances[-1].call_order.append(("refresh_config", workspace.directory))
-
- monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True)
- )
-
- flow = DummyFlow.instances[-1]
- assert result == {"step": "Floorplan", "state": "Success"}
- assert refreshed == [ws.resolve()]
- assert flow.call_order == [
- ("refresh_config", ws.resolve()),
- ("init_db_engine",),
- ("run_step", "Floorplan", True),
- ]
-
-
-def test_flow_run_step_rerun_clears_step_artifacts_and_resets_step_state(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- step_dir = ws / "Floorplan_ecc"
- artifact_dirs = [
- step_dir / "output",
- step_dir / "data",
- step_dir / "feature",
- step_dir / "analysis",
- step_dir / "report",
- step_dir / "log",
- ]
- for directory in artifact_dirs:
- (directory / "nested").mkdir(parents=True)
- (directory / "nested" / "stale").write_text("stale")
- script_dir = step_dir / "script"
- script_dir.mkdir()
- (script_dir / "keep.tcl").write_text("keep")
-
- subflow_path = step_dir / "subflow.json"
- subflow_path.write_text(
- json.dumps(
- {
- "path": str(subflow_path),
- "steps": [
- {
- "name": "load data",
- "state": "Success",
- "runtime": "0:00:03",
- "peak memory (mb)": 24,
- "info": {"instances": 12},
- }
- ],
- }
- )
- )
- checklist_path = step_dir / "checklist.json"
- checklist_path.write_text(
- json.dumps(
- {
- "path": str(checklist_path),
- "checklist": [{"item": "stale", "state": "Success"}],
- }
- )
- )
- DummyFlow.workspace_step_specs = (
- {
- "name": "Floorplan",
- "tool": "ecc",
- "output": {"dir": step_dir / "output"},
- "data": {"dir": step_dir / "data"},
- "feature": {"dir": step_dir / "feature"},
- "analysis": {"dir": step_dir / "analysis"},
- "report": {"dir": step_dir / "report"},
- "log": {"dir": step_dir / "log"},
- "subflow": SimpleNamespace(path=subflow_path, steps=[]),
- "checklist": SimpleNamespace(path=checklist_path, checklist=[]),
- },
- )
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- session = api.sessions.get_session(workspace_id)
- session.workspace.flow.data = {
- "steps": [
- {
- "name": "Floorplan",
- "tool": "ecc",
- "state": "Success",
- "runtime": "0:00:04",
- "peak memory (mb)": 30,
- }
- ]
- }
-
- result = api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True)
- )
-
- assert result == {"step": "Floorplan", "state": "Success"}
- assert all(list(directory.iterdir()) == [] for directory in artifact_dirs)
- assert (script_dir / "keep.tcl").read_text() == "keep"
- flow_record = next(
- record
- for record in session.workspace.flow.data["steps"]
- if record["name"] == "Floorplan" and record["tool"] == "ecc"
- )
- assert flow_record == {
- "name": "Floorplan",
- "tool": "ecc",
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- "info": {},
- }
- assert json.loads(subflow_path.read_text()) == {
- "path": str(subflow_path),
- "steps": [
- {
- "name": "load data",
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- "info": {},
- }
- ],
- }
- checklist = json.loads(checklist_path.read_text())
- assert checklist["schema_version"] == 3
- assert checklist["kind"] == "signoff_checklist"
- assert checklist["checker_revision"] == "signoff-v1"
- assert checklist["status"] == "ready"
- assert checklist["summary"] == {
- "passed": 0,
- "blocked": 0,
- "attention": 0,
- "unavailable": 0,
- }
- assert checklist["checklist"] == []
-
-
-def test_flow_run_step_gui_rerun_resets_target_and_downstream_subflows(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
-
- def step_spec(name, tool):
- step_dir = ws / f"{name}_{tool}"
- artifact_dir = step_dir / "output"
- (artifact_dir / "nested").mkdir(parents=True)
- (artifact_dir / "nested" / "stale").write_text(name)
- subflow_path = step_dir / "subflow.json"
- subflow_path.write_text(
- json.dumps(
- {
- "path": str(subflow_path),
- "steps": [
- {
- "name": "load data",
- "state": "Success",
- "runtime": "0:00:01",
- "peak memory (mb)": 10,
- "info": {"stale": name},
- },
- {
- "name": "run tool",
- "state": "Success",
- "runtime": "0:00:02",
- "peak memory (mb)": 20,
- "info": {"stale": name},
- },
- ],
- }
- )
- )
- checklist_path = step_dir / "checklist.json"
- checklist_path.write_text(json.dumps({"checklist": [{"item": name}]}))
- return {
- "name": name,
- "tool": tool,
- "output": {"dir": artifact_dir},
- "subflow": SimpleNamespace(path=subflow_path, steps=[]),
- "checklist": SimpleNamespace(path=checklist_path, checklist=[]),
- }
-
- synthesis = step_spec("Synthesis", "yosys")
- floorplan = step_spec("Floorplan", "ecc")
- route = step_spec("route", "ecc")
- DummyFlow.workspace_step_specs = (synthesis, floorplan, route)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- session = api.sessions.get_session(workspace_id)
- session.workspace.flow.data = {
- "steps": [
- {
- "name": spec["name"],
- "tool": spec["tool"],
- "state": "Success",
- "runtime": "0:00:04",
- "peak memory (mb)": 30,
- "info": {"stale": spec["name"]},
- }
- for spec in (synthesis, floorplan, route)
- ]
- }
-
- result = api._flow_run_step(
- FlowRunStepRequest(
- workspace_id=workspace_id,
- step="Floorplan",
- rerun=True,
- ),
- reset_dependents=True,
- )
-
- assert result == {"step": "Floorplan", "state": "Success"}
- assert (ws / "Synthesis_yosys" / "output" / "nested" / "stale").read_text() == "Synthesis"
- for spec in (floorplan, route):
- assert list(spec["output"]["dir"].iterdir()) == []
- checklist = json.loads(spec["checklist"].path.read_text())
- assert checklist["schema_version"] == 3
- assert checklist["kind"] == "signoff_checklist"
- assert checklist["checker_revision"] == "signoff-v1"
- assert checklist["status"] == "ready"
- assert checklist["summary"] == {
- "passed": 0,
- "blocked": 0,
- "attention": 0,
- "unavailable": 0,
- }
- assert checklist["checklist"] == []
- reset_subflow = json.loads(spec["subflow"].path.read_text())
- assert all(
- step["state"] == "Unstart"
- and step["runtime"] == ""
- and step["peak memory (mb)"] == 0
- and step["info"] == {}
- for step in reset_subflow["steps"]
- )
-
- records = {record["name"]: record for record in session.workspace.flow.data["steps"]}
- assert any(
- record["name"] == "Synthesis" and record["state"] == "Success"
- for record in session.workspace.flow.data["steps"]
- )
- for name in ("Floorplan", "route"):
- assert records[name] == {
- "name": name,
- "tool": "ecc",
- "state": "Unstart",
- "runtime": "",
- "peak memory (mb)": 0,
- "info": {},
- }
-
-
-def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
- api.sessions.get_session(workspace_id).layout_edit_session = object()
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True)
- )
-
- assert exc_info.value.code == "layout_edit_active"
- assert "close the rendered layout" in exc_info.value.message
- assert DummyFlow.instances[-1].run_calls == []
-
-
-def test_flow_run_step_skips_successful_step_without_db_init(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- DummyFlow.successful_steps = {"Synthesis"}
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- result = api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False)
- )
-
- flow = DummyFlow.instances[-1]
- assert result == {"step": "Synthesis", "state": "Success"}
- assert flow.init_db_engine_calls == 0
- assert flow.call_order == [("run_step", "Synthesis", False)]
-
-
-def test_flow_run_step_unknown_step_returns_runtime_error(monkeypatch, tmp_path):
- _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path)
- api = WorkspaceRuntimeApi()
- workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"]
-
- with pytest.raises(RuntimeApiError) as exc_info:
- api.flow_run_step(
- FlowRunStepRequest(workspace_id=workspace_id, step="Missing", rerun=False)
- )
-
- assert exc_info.value.code == "command_failed"
- assert "step not found" in exc_info.value.message
-
-
-@pytest.mark.parametrize(
- "db_value",
- [None, "", "some/db/path"],
-)
-def test_build_workspace_step_for_info_forwards_db_from_any_predecessor(tmp_path, db_value):
- # Regression: a Yosys (synthesis) predecessor has output.db == None on the
- # base OutputPaths contract, so reconstructing the next step must not crash.
- from pathlib import Path
-
- from chipcompiler.data import (
- EccOutput,
- EccStep,
- OriginDesign,
- Workspace,
- YosysStep,
- )
- from chipcompiler.runtime.workspace_api import _build_workspace_step_for_info
-
- workspace = Workspace(
- directory=tmp_path,
- design=OriginDesign(name="gcd", top_module="gcd"),
- )
-
- # Yosys predecessor: db is the base default (None) -> must reconstruct cleanly.
- yosys_prev = YosysStep(name="Synthesis")
- ecc_step = _build_workspace_step_for_info(
- workspace, {"name": "Floorplan", "tool": "ecc"}, yosys_prev
- )
- assert isinstance(ecc_step, EccStep)
-
- # ECC/sizer predecessor: db forwarded unchanged (None / "" / a real path).
- ecc_prev = EccStep(
- name="place",
- output=EccOutput(
- def_=tmp_path / "p.def",
- verilog=tmp_path / "p.v",
- db=Path(db_value) if db_value else db_value,
- ),
- )
- next_step = _build_workspace_step_for_info(workspace, {"name": "CTS", "tool": "ecc"}, ecc_prev)
- assert isinstance(next_step, EccStep)
- # A real db path is forwarded into the next step's input.db; "" / None -> None.
- if db_value:
- assert next_step.input.db == Path(db_value)
- else:
- assert next_step.input.db is None
diff --git a/uv.lock b/uv.lock
index aedf194b..353e83f3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -64,15 +64,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
-[[package]]
-name = "attrs"
-version = "26.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
-]
-
[[package]]
name = "auditwheel"
version = "6.6.0"
@@ -451,7 +442,6 @@ dependencies = [
{ name = "ecc-dreamplace" },
{ name = "ecc-tools-bin" },
{ name = "fastapi" },
- { name = "jsonrpcserver" },
{ name = "klayout" },
{ name = "matplotlib" },
{ name = "numpy" },
@@ -493,7 +483,6 @@ requires-dist = [
{ name = "ecc-dreamplace", editable = "chipcompiler/thirdparty/ecc-dreamplace" },
{ name = "ecc-tools-bin", editable = "chipcompiler/thirdparty/ecc-tools" },
{ name = "fastapi", specifier = ">=0.109" },
- { name = "jsonrpcserver", specifier = ">=5.0.9" },
{ name = "klayout", specifier = ">=0.30.2" },
{ name = "matplotlib", specifier = ">=3.4" },
{ name = "numpy", specifier = ">=1.21" },
@@ -755,43 +744,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
-[[package]]
-name = "jsonrpcserver"
-version = "5.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jsonschema" },
- { name = "oslash" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/3b/8d4d4fe8c59a1a4d1e6edd6126ec118b989510fadf262950a5c4f4bca664/jsonrpcserver-5.0.9.tar.gz", hash = "sha256:a71fb2cfa18541c80935f60987f92755d94d74141248c7438847b96eee5c4482", size = 14506, upload-time = "2022-09-15T02:28:24.004Z" }
-
-[[package]]
-name = "jsonschema"
-version = "4.26.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "jsonschema-specifications" },
- { name = "referencing" },
- { name = "rpds-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
-]
-
-[[package]]
-name = "jsonschema-specifications"
-version = "2025.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "referencing" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
-]
-
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -1369,18 +1321,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" },
]
-[[package]]
-name = "oslash"
-version = "0.6.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/60/b2/54ea4a7c6f768469a4c6a2f27f5c7cf572d63e9fd7f7618fca89c30966b3/OSlash-0.6.3.tar.gz", hash = "sha256:868aeb58a656f2ed3b73d9dd6abe387b20b74fc9413d3e8653b615b15bf728f3", size = 35228, upload-time = "2020-10-12T20:50:14.165Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2d/c3/77d40a6e20fdfbf92b086d2c47e3cc82731e179e3f44bdc8e60b7306bcc3/OSlash-0.6.3-py3-none-any.whl", hash = "sha256:89b978443b7db3ac2666106bdc3680add3c886a6d8fcdd02fd062af86d29494f", size = 26943, upload-time = "2020-10-12T20:50:13.06Z" },
-]
-
[[package]]
name = "packaging"
version = "25.0"
@@ -2156,20 +2096,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
-[[package]]
-name = "referencing"
-version = "0.37.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "rpds-py" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
-]
-
[[package]]
name = "rich"
version = "15.0.0"
@@ -2192,129 +2118,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/e1/716c06dc81db6065db98325fbba92631198de9c288ad57d50231cee6277f/rosettakit-0.2.0-py3-none-any.whl", hash = "sha256:b91438a445a1d14f5e70442b4152ea4fd398641a0b62955fc0f156ce7771b1de", size = 15725, upload-time = "2026-06-22T08:12:50.669Z" },
]
-[[package]]
-name = "rpds-py"
-version = "2026.6.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
- { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
- { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
- { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
- { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
- { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
- { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
- { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
- { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
- { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
- { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
- { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
- { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
- { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
- { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
- { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
- { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
- { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
- { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
- { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
- { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
- { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
- { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
- { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
- { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
- { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
- { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
- { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
- { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
- { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
- { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
- { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
- { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
- { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
- { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
- { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
- { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
- { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
- { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
- { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
- { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
- { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
- { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
- { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
- { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
- { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
- { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
- { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
- { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
- { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
- { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
- { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
- { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
- { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
- { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
- { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
- { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
- { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
- { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
- { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
- { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
- { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
- { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
- { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
- { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
- { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
- { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
- { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
- { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
- { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
- { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
- { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
- { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
- { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
- { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
- { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
- { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
- { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
- { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
- { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
- { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
- { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
- { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
- { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
- { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
- { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
- { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
- { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
- { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
- { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
- { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
- { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
- { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
- { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
- { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
- { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
- { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
- { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
- { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
- { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
- { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
- { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
- { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
- { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
- { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
- { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
- { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
-]
-
[[package]]
name = "ruff"
version = "0.14.13"
From b20d51fa4a5dce38b106372820b5f37c79d20de2 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 01:48:40 +0800
Subject: [PATCH 09/25] fix(workspace): complete cross-creator update contract
---
chipcompiler/cli/command_handlers/project.py | 14 ++
chipcompiler/engine/__init__.py | 2 +
chipcompiler/engine/workspace_lifecycle.py | 143 ++++++++++++++++++-
test/engine/test_workspace_spec.py | 100 +++++++++++++
4 files changed, 255 insertions(+), 4 deletions(-)
diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py
index 692ca15b..8df05f93 100644
--- a/chipcompiler/cli/command_handlers/project.py
+++ b/chipcompiler/cli/command_handlers/project.py
@@ -443,6 +443,20 @@ def error(kind: str, **fields) -> CommandResult:
return error("flow_range_requires_pair")
if fresh_target and (command_input.resume or command_input.only is not None):
return error("selector_requires_workspace")
+ if not fresh_target and ctx.project_state == "manifest" and cfg.manifest_driven:
+ return run_dispatch.dispatch_project_run(
+ command_input,
+ ctx,
+ cfg,
+ run_dir,
+ run_name,
+ cli_overrides,
+ flow_config,
+ project_state,
+ warning_records,
+ workspace_registered=workspace_registered,
+ execute_flow=execute_flow,
+ )
errors = effective_config.validate_effective(
ctx,
cfg,
diff --git a/chipcompiler/engine/__init__.py b/chipcompiler/engine/__init__.py
index 95a7ad30..c523bb8e 100644
--- a/chipcompiler/engine/__init__.py
+++ b/chipcompiler/engine/__init__.py
@@ -17,6 +17,7 @@
assess_execution_readiness,
create_workspace_from_spec,
describe_workspace_binding_requirement,
+ update_workspace_from_spec,
)
from .workspace_spec import describe_workspace_spec, validate_workspace_spec
@@ -39,6 +40,7 @@
"read_step_configuration_from_directory",
"read_workspace_configuration",
"read_workspace_configuration_from_directory",
+ "update_workspace_from_spec",
"update_workspace_step_configuration",
"update_workspace_configuration",
"validate_workspace_spec",
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index 679e4ca5..97c826e0 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -1,5 +1,8 @@
+import ctypes
+import errno
import hashlib
import json
+import os
import shutil
import tempfile
from pathlib import Path
@@ -26,7 +29,7 @@ def describe_workspace_binding_requirement(
return {
"familyId": workspace.pdk.name,
"version": workspace.pdk.version or "unversioned",
- "mode": "default",
+ "mode": "manual" if workspace.parameters.data.get("pdk_config") else "default",
}
@@ -44,7 +47,12 @@ def assess_execution_readiness(
if not isinstance(root, str) or not Path(root).is_dir():
return {"ready": False, "code": "pdk_binding_missing"}
try:
- get_pdk(workspace.pdk.name, pdk_root=root).validate()
+ if workspace.parameters.data.get("pdk_config"):
+ if workspace.pdk.root is None or Path(root).resolve() != workspace.pdk.root.resolve():
+ return {"ready": False, "code": "pdk_binding_mismatch"}
+ workspace.pdk.validate()
+ else:
+ get_pdk(workspace.pdk.name, pdk_root=root).validate()
except (OSError, ValueError):
return {"ready": False, "code": "pdk_binding_mismatch"}
return {"ready": True}
@@ -57,7 +65,12 @@ def apply_workspace_bindings(workspace, bindings: object) -> None:
if not isinstance(root, str) or not Path(root).is_dir():
raise WorkspaceLifecycleError("pdk_binding_missing", "PDK binding root is required")
sdc, spef = workspace.pdk.sdc, workspace.pdk.spef
- workspace.pdk = get_pdk(workspace.pdk.name, pdk_root=root)
+ if workspace.parameters.data.get("pdk_config"):
+ if workspace.pdk.root is None or Path(root).resolve() != workspace.pdk.root.resolve():
+ raise WorkspaceLifecycleError("pdk_binding_mismatch", "PDK binding root does not match")
+ workspace.pdk.validate()
+ else:
+ workspace.pdk = get_pdk(workspace.pdk.name, pdk_root=root)
workspace.pdk.sdc, workspace.pdk.spef = sdc, spef
from chipcompiler.data import refresh_workspace_config
@@ -118,12 +131,19 @@ def create_workspace_from_spec(
)
generated_filelist: str | None = None
+ generated_pdk_config: str | None = None
if len(rtl_paths) > 1:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", suffix=".f", delete=False
) as handle:
handle.write("\n".join(rtl_paths) + "\n")
generated_filelist = handle.name
+ if isinstance(pdk, PDK):
+ with tempfile.NamedTemporaryFile(
+ "w", encoding="utf-8", suffix=".json", delete=False
+ ) as handle:
+ json.dump(_pdk_config(pdk), handle)
+ generated_pdk_config = handle.name
try:
workspace = create_workspace(
@@ -136,8 +156,9 @@ def create_workspace_from_spec(
golden_verilog=input_paths.get("goldenNetlist", ""),
sdc=input_paths.get("sdc", ""),
spef=input_paths.get("spef", ""),
- pdk=pdk,
+ pdk=pdk.name if isinstance(pdk, PDK) else pdk,
pdk_root=pdk_root,
+ pdk_json=generated_pdk_config or "",
pdk_overrides=pdk_overrides,
parameters=backend_parameters,
flow_config=flow_config,
@@ -161,6 +182,78 @@ def create_workspace_from_spec(
finally:
if generated_filelist is not None:
Path(generated_filelist).unlink(missing_ok=True)
+ if generated_pdk_config is not None:
+ Path(generated_pdk_config).unlink(missing_ok=True)
+
+
+def update_workspace_from_spec(
+ target_directory: str | Path,
+ expected_workspace_revision: int,
+ spec: object,
+ bindings: object,
+ command_id: str = "",
+):
+ from chipcompiler.engine.snapshot import ensure_engineering_snapshot
+
+ target = Path(target_directory).expanduser().resolve()
+ fingerprint = _workspace_command_fingerprint(
+ "update", spec, bindings, expected_workspace_revision
+ )
+ if command_id and _command_retry_matches(target, command_id, fingerprint):
+ return _load_committed_workspace(target)
+ current = _load_committed_workspace(target)
+ snapshot = ensure_engineering_snapshot(current)
+ if snapshot["workspaceRevision"] != expected_workspace_revision:
+ raise WorkspaceLifecycleError(
+ "revision_conflict",
+ "Workspace Revision does not match",
+ {
+ "expectedWorkspaceRevision": expected_workspace_revision,
+ "actualWorkspaceRevision": snapshot["workspaceRevision"],
+ },
+ )
+
+ staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.staging-", dir=target.parent))
+ staging.rmdir()
+ try:
+ staged = create_workspace_from_spec(staging, spec, bindings)
+ create_engineering_snapshot(
+ staged,
+ workspace_id=snapshot["workspaceId"],
+ workspace_revision=snapshot["workspaceRevision"] + 1,
+ cause="workspace.updated",
+ )
+ _copy_workspace_commands(target, staging)
+ _write_workspace_command(
+ staging,
+ command_id,
+ fingerprint,
+ snapshot["workspaceId"],
+ snapshot["workspaceRevision"] + 1,
+ )
+ _rewrite_workspace_paths(staging, target)
+ _exchange_directories(target, staging)
+ try:
+ return _load_committed_workspace(target)
+ except Exception:
+ _exchange_directories(target, staging)
+ raise
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+
+
+def _pdk_config(pdk: PDK) -> dict[str, Any]:
+ return {
+ "name": pdk.name,
+ "version": pdk.version,
+ "root": str(pdk.root or ""),
+ "tech": str(pdk.tech or ""),
+ "lefs": [str(path) for path in pdk.lefs],
+ "libs": [str(path) for path in pdk.libs],
+ "mapping_file": str(pdk.mapping_file or ""),
+ "dont_use": pdk.dont_use,
+ "abc_load": pdk.abc_load,
+ }
def _bound_pdk(spec: dict, binding: dict) -> tuple[PDK | str, str, dict | None]:
@@ -271,3 +364,45 @@ def _write_workspace_command(
}
if not json_write(path / "home" / "workspace-commands.json", payload):
raise OSError("Failed to persist Workspace command ledger")
+
+
+def _copy_workspace_commands(source: Path, destination: Path) -> None:
+ source_path = source / "home" / "workspace-commands.json"
+ if source_path.is_file():
+ shutil.copy2(source_path, destination / "home" / "workspace-commands.json")
+
+
+def _rewrite_workspace_paths(source_root: Path, target_root: Path) -> None:
+ from chipcompiler.utility import json_write
+
+ source = str(source_root)
+ target = str(target_root)
+ for path in source_root.rglob("*.json"):
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ continue
+ rewritten = _replace_string_prefix(value, source, target)
+ if rewritten != value and not json_write(path, rewritten):
+ raise OSError(f"Failed to rewrite staged Workspace path: {path}")
+
+
+def _replace_string_prefix(value: Any, source: str, target: str) -> Any:
+ if isinstance(value, str):
+ return target + value[len(source) :] if value.startswith(source) else value
+ if isinstance(value, list):
+ return [_replace_string_prefix(item, source, target) for item in value]
+ if isinstance(value, dict):
+ return {key: _replace_string_prefix(item, source, target) for key, item in value.items()}
+ return value
+
+
+def _exchange_directories(left: Path, right: Path) -> None:
+ libc = ctypes.CDLL(None, use_errno=True)
+ renameat2 = getattr(libc, "renameat2", None)
+ if os.name != "posix" or renameat2 is None:
+ raise OSError(errno.ENOTSUP, "atomic Workspace Update is unavailable")
+ if renameat2(-100, os.fsencode(left), -100, os.fsencode(right), 2) == 0:
+ return
+ error = ctypes.get_errno()
+ raise OSError(error, os.strerror(error))
diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py
index fbe833d2..d67d5a4f 100644
--- a/test/engine/test_workspace_spec.py
+++ b/test/engine/test_workspace_spec.py
@@ -2,6 +2,8 @@
from copy import deepcopy
from pathlib import Path
+import pytest
+
from chipcompiler.engine.workspace_spec import (
describe_workspace_spec,
validate_workspace_spec,
@@ -149,3 +151,101 @@ def test_validated_workspace_spec_creates_main_compatible_workspace(
assert reopened.design.name == "gcd"
assert reopened.parameters.data["frequency_max"] == 200.0
assert reopened.flow.steps()[0]["name"] == "Synthesis"
+
+
+def test_manual_pdk_workspace_reopens_through_main_persistence(tmp_path):
+ from chipcompiler.data import load_workspace
+ from chipcompiler.engine import (
+ assess_execution_readiness,
+ create_workspace_from_spec,
+ describe_workspace_binding_requirement,
+ )
+
+ rtl = tmp_path / "gcd.v"
+ rtl.write_text("module gcd(input clk); endmodule\n")
+ pdk_root = tmp_path / "pdk"
+ pdk_root.mkdir()
+ for name in ("tech.lef", "cells.lef", "typ.lib"):
+ (pdk_root / name).write_text(name)
+ spec = _valid_spec()
+ spec["flow"] = {"flowId": "syn_sta"}
+ spec["pdk"] = {
+ "familyId": "ics55",
+ "mode": "manual",
+ "files": [
+ {"fileId": "tech", "role": "tech"},
+ {"fileId": "cells", "role": "lef"},
+ {"fileId": "lib", "role": "liberty"},
+ ],
+ }
+ bindings = {
+ "inputs": {"rtl-main": str(rtl)},
+ "pdk": {
+ "root": str(pdk_root),
+ "files": {
+ "tech": str(pdk_root / "tech.lef"),
+ "cells": str(pdk_root / "cells.lef"),
+ "lib": str(pdk_root / "typ.lib"),
+ },
+ },
+ }
+ target = tmp_path / "workspace"
+
+ create_workspace_from_spec(target, spec, bindings)
+ reopened = load_workspace(target)
+
+ assert reopened.pdk.tech == pdk_root / "tech.lef"
+ assert reopened.pdk.lefs == [pdk_root / "cells.lef"]
+ assert describe_workspace_binding_requirement(target)["mode"] == "manual"
+ assert assess_execution_readiness(target, bindings) == {"ready": True}
+
+
+def test_workspace_spec_update_is_atomic_revisioned_and_idempotent(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ from chipcompiler.engine import (
+ WorkspaceLifecycleError,
+ create_workspace_from_spec,
+ update_workspace_from_spec,
+ )
+ from chipcompiler.engine.snapshot import read_engineering_snapshot
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ target = tmp_path / "workspace"
+ created = create_workspace_from_spec(target, payload["workspaceSpec"], bindings, "create-1")
+ before = read_engineering_snapshot(created)
+ updated_spec = deepcopy(payload["workspaceSpec"])
+ updated_spec["parameters"]["design.frequency_mhz"] = 250.0
+
+ updated = update_workspace_from_spec(
+ target,
+ before["workspaceRevision"],
+ updated_spec,
+ bindings,
+ "update-1",
+ )
+ after = read_engineering_snapshot(updated)
+ repeated = update_workspace_from_spec(
+ target,
+ before["workspaceRevision"],
+ updated_spec,
+ bindings,
+ "update-1",
+ )
+
+ assert after["workspaceId"] == before["workspaceId"]
+ assert after["workspaceRevision"] == before["workspaceRevision"] + 1
+ assert read_engineering_snapshot(repeated) == after
+ assert repeated.parameters.data["frequency_max"] == 250.0
+
+ with pytest.raises(WorkspaceLifecycleError) as conflict:
+ update_workspace_from_spec(
+ target,
+ before["workspaceRevision"],
+ payload["workspaceSpec"],
+ bindings,
+ "update-2",
+ )
+ assert conflict.value.code == "revision_conflict"
+ assert read_engineering_snapshot(repeated) == after
From 1c4f4af86f600a762c26cfe22d6baaedb14253b6 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 02:22:33 +0800
Subject: [PATCH 10/25] fix(workspace): preserve rollback evidence atomically
---
chipcompiler/data/parameter_keys.py | 2 +-
chipcompiler/data/workspace_config.py | 2 +-
chipcompiler/docs/ecc-user-guide.cn.md | 1 -
chipcompiler/docs/ecc-user-guide.en.md | 1 -
chipcompiler/engine/snapshot.py | 4 ++--
chipcompiler/engine/workspace_configuration.py | 15 ++++++++++++++-
docs/development.cn.md | 3 +--
docs/specification/cli-design.md | 13 +++++--------
test/data/test_workspace_config.py | 2 +-
9 files changed, 25 insertions(+), 18 deletions(-)
diff --git a/chipcompiler/data/parameter_keys.py b/chipcompiler/data/parameter_keys.py
index 8478d4bb..1cc2d9a3 100644
--- a/chipcompiler/data/parameter_keys.py
+++ b/chipcompiler/data/parameter_keys.py
@@ -6,7 +6,7 @@
``top_module``, ``die``/``core`` subtrees, ...). Older workspaces persisted
display-oriented keys (``"Frequency max [MHz]"``, ``"Top module"``, ...) and
the GUI sends flat keys plus a handful of positional geometry aliases at the
-project/RPC boundary. This module is the only place that knows the legacy
+project/adapter boundary. This module is the only place that knows the legacy
vocabulary; everything else consumes the canonical form.
"""
diff --git a/chipcompiler/data/workspace_config.py b/chipcompiler/data/workspace_config.py
index 97df9411..941b8b38 100644
--- a/chipcompiler/data/workspace_config.py
+++ b/chipcompiler/data/workspace_config.py
@@ -140,7 +140,7 @@ def validate_flow_config(flow: object) -> dict[str, str]:
if not isinstance(value, str):
raise WorkspaceFlowTargetError(f"[flow] {key} must be a string: {value!r}")
# Workspace files carry canonical step names only; display-name
- # aliases are translated at the manifest/RPC boundary, never here.
+ # aliases are translated at the manifest/adapter boundary, never here.
if value not in canonical_names:
raise WorkspaceFlowTargetError(f"[flow] unknown step name: {value!r}")
normalized[key] = value
diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md
index d1f181b6..c691e764 100644
--- a/chipcompiler/docs/ecc-user-guide.cn.md
+++ b/chipcompiler/docs/ecc-user-guide.cn.md
@@ -110,7 +110,6 @@ Commands:
workspace Refresh managed workspaces from project configuration
signoff Inspect and export signoff packages
report Generate design-summary, QoR score, checklist, and step reports
- rpc Run the private ECC JSON-RPC runtime
```
## 1.5. doc — 在终端阅读内置指南
diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md
index 766c4614..398dbded 100644
--- a/chipcompiler/docs/ecc-user-guide.en.md
+++ b/chipcompiler/docs/ecc-user-guide.en.md
@@ -110,7 +110,6 @@ Commands:
workspace Refresh managed workspaces from project configuration
signoff Inspect and export signoff packages
report Generate design-summary, QoR score, checklist, and step reports
- rpc Run the private ECC JSON-RPC runtime
```
## 1.5. doc — read the bundled guides in the terminal
diff --git a/chipcompiler/engine/snapshot.py b/chipcompiler/engine/snapshot.py
index 86984912..8ccd332a 100644
--- a/chipcompiler/engine/snapshot.py
+++ b/chipcompiler/engine/snapshot.py
@@ -80,9 +80,9 @@ def commit_engineering_snapshot(
]
if remaining:
snapshot["stalePredecessor"] = {**deepcopy(stale), "invalidatedStepIds": remaining}
- else:
- _stale_snapshot_path(workspace).unlink(missing_ok=True)
_write_snapshot(_snapshot_path(workspace), snapshot)
+ if isinstance(stale, dict) and "stalePredecessor" not in snapshot:
+ _stale_snapshot_path(workspace).unlink(missing_ok=True)
return snapshot
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
index 196e62a6..dcd68470 100644
--- a/chipcompiler/engine/workspace_configuration.py
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -1,3 +1,5 @@
+import os
+import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -425,4 +427,15 @@ def _restore_files(snapshot: dict[Path, bytes | None]) -> None:
path.unlink(missing_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(content)
+ fd, temporary = tempfile.mkstemp(
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
+ )
+ try:
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary, path)
+ except BaseException:
+ os.unlink(temporary)
+ raise
diff --git a/docs/development.cn.md b/docs/development.cn.md
index d51857f7..658907aa 100644
--- a/docs/development.cn.md
+++ b/docs/development.cn.md
@@ -247,8 +247,7 @@ chipcompiler/cli/commands/ # typer 命令定义层(薄)
├── project_config.py # project 子应用(set/unset/add/remove/show)
├── workspace.py # workspace 子应用(refresh)
├── signoff.py # signoff 子应用(inspect/export)
- ├── report.py # report 子应用(summary/qor/checklist/step)
- └── rpc.py # rpc 子应用(serve)
+ └── report.py # report 子应用(summary/qor/checklist/step)
chipcompiler/cli/command_handlers/ # 业务处理层(唯一的处理器包,有状态/重逻辑)
├── project.py # init / check / run / migrate / workspace refresh(含 preset 解析与环境预检)
├── inspect.py # status / log / config
diff --git a/docs/specification/cli-design.md b/docs/specification/cli-design.md
index b033d13a..7ac9d292 100644
--- a/docs/specification/cli-design.md
+++ b/docs/specification/cli-design.md
@@ -187,8 +187,8 @@ agent-specific disclosure fields inside core flow APIs.
### Core Commands
The current root surface is a Typer command graph. The project-first command
-surface stays small, with version reporting and the private runtime sidecar
-available as explicit root entries:
+surface stays small, with version reporting and layout rendering as explicit
+root entries:
```bash
ecc --version
@@ -207,7 +207,6 @@ ecc project
ecc workspace
ecc signoff
ecc report
-ecc rpc
ecc layout-image
```
@@ -231,7 +230,6 @@ Responsibilities:
| `ecc workspace` | Refresh a declared workspace from current `ecc.toml` without running it |
| `ecc signoff` | Inspect package readiness and export the tar.gz package |
| `ecc report` | Write design-summary, QoR, and checklist reports; show step evidence |
-| `ecc rpc` | Serve the private JSON-RPC runtime sidecar over stdio |
| `ecc layout-image` | Render a GDS file into an image |
`ecc run` preflights the tools its preset needs (yosys for synthesis,
@@ -276,7 +274,7 @@ The command graph follows these rules; new commands must follow them too:
`check`, `run`, `status`, `log`, `config`, `doctor`, `migrate`, `version`)
plus the frozen tool invocations (`layout-image`). Resource management and
reporting live in noun groups (`param`, `pdk`, `project`, `workspace`,
- `signoff`, `report`, `rpc`).
+ `signoff`, `report`).
- **Subcommand verbs.** Mutable resources use the CRUD set
(`list`, `show`, `set`, `unset`, `diff`). The `report`
group names its artifacts instead (`summary`, `qor`, `checklist`, `step`)
@@ -715,9 +713,8 @@ NAME` for a workspace declared in the project manifest. It persists a
workspace-local override and refreshes the affected configuration without
running the flow. PDK resources and input references are project-level values:
edit `ecc.toml` through `ecc project` (or the matching `ecc pdk`/`ecc param`
-commands), then use `ecc workspace refresh NAME`. Old workspace create/run
-automation should use the private JSON-RPC runtime sidecar. The long-term
-default is project-oriented and configuration-driven through `ecc.toml` and
+commands), then use `ecc workspace refresh NAME`. The supported integration
+surface is project-oriented and configuration-driven through `ecc.toml` and
subcommands such as `ecc run --project `.
The project-level Python APIs should remain compatible with existing Python
diff --git a/test/data/test_workspace_config.py b/test/data/test_workspace_config.py
index 94ec050f..790f5663 100644
--- a/test/data/test_workspace_config.py
+++ b/test/data/test_workspace_config.py
@@ -144,7 +144,7 @@ def test_flow_validation_accepts_canonical_names():
def test_flow_validation_rejects_display_name_aliases():
# Workspace files carry canonical names only; aliases translate at the
- # manifest/RPC boundary.
+ # manifest/adapter boundary.
with pytest.raises(WorkspaceFlowTargetError):
validate_flow_config({"start": "Synth", "end": "Filler"})
From d1cbde119f8612f89a805ff073a8eee1fef77995 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 03:45:24 +0800
Subject: [PATCH 11/25] refactor(data): centralize parameter catalog ownership
Move the public parameter schema and workspace override helpers into the data layer while keeping CLI compatibility facades.
---
chipcompiler/cli/project/params.py | 673 +-----------------
chipcompiler/cli/project/workspace_params.py | 143 +---
chipcompiler/data/parameter_schema.py | 491 +++++++++++++
chipcompiler/data/workspace_parameters.py | 127 ++++
.../engine/workspace_configuration.py | 126 +++-
chipcompiler/engine/workspace_lifecycle.py | 45 +-
chipcompiler/engine/workspace_spec.py | 2 +-
7 files changed, 763 insertions(+), 844 deletions(-)
create mode 100644 chipcompiler/data/parameter_schema.py
create mode 100644 chipcompiler/data/workspace_parameters.py
diff --git a/chipcompiler/cli/project/params.py b/chipcompiler/cli/project/params.py
index b4db5c0b..6241f7d7 100644
--- a/chipcompiler/cli/project/params.py
+++ b/chipcompiler/cli/project/params.py
@@ -1,682 +1,27 @@
-import copy
-import json
-from dataclasses import dataclass
+"""CLI compatibility facade for the canonical data parameter catalog."""
-from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS
-from chipcompiler.data.config_params.common import ParamSchema
+from dataclasses import replace
-_LEGACY_PARAM_REGISTRY: tuple[ParamSchema, ...] = (
- ParamSchema(
- param="design.frequency_mhz",
- group="design",
- name="frequency_mhz",
- type="float",
- default=100.0,
- applies="synthesis",
- maps_to="frequency_max",
- description="Target clock frequency in MHz",
- range=(1e-6, 10000.0),
- unit="MHz",
- example="200.0",
- ),
- ParamSchema(
- param="floorplan.core_util",
- group="floorplan",
- name="core_util",
- type="float",
- default=0.4,
- applies="floorplan",
- maps_to={"core": "utilitization"},
- description="Core utilization ratio",
- range=(0.01, 1.0),
- example="0.45",
- ),
- ParamSchema(
- param="floorplan.core_margin",
- group="floorplan",
- name="core_margin",
- type="list[int]",
- default=[2, 2],
- applies="floorplan",
- maps_to={"core": "margin"},
- description="Core margin in micrometers [horizontal, vertical]",
- example="[2, 2]",
- ),
- ParamSchema(
- param="floorplan.aspect_ratio",
- group="floorplan",
- name="aspect_ratio",
- type="float",
- default=1.0,
- applies="floorplan",
- maps_to={"core": "aspect_ratio"},
- description="Core aspect ratio (width/height)",
- range=(0.1, 10.0),
- example="1.0",
- ),
- ParamSchema(
- param="cts.max_fanout",
- group="cts",
- name="max_fanout",
- type="int",
- default=20,
- applies="cts",
- maps_to="max_fanout",
- description="Maximum fanout for clock tree synthesis",
- range=(1, 200),
- example="16",
- ),
- ParamSchema(
- param="place.target_density",
- group="place",
- name="target_density",
- type="float",
- default=0.2,
- applies="placement",
- maps_to={"dreamplace": "target_density"},
- description="Target placement density",
- range=(0.1, 0.95),
- example="0.65",
- ),
- ParamSchema(
- param="place.target_overflow",
- group="place",
- name="target_overflow",
- type="float",
- default=0.1,
- applies="placement",
- maps_to={"dreamplace": "stop_overflow"},
- description="Target overflow for global placement",
- range=(0.0, 1.0),
- example="0.08",
- ),
- ParamSchema(
- param="place.global_right_padding",
- group="place",
- name="global_right_padding",
- type="int",
- default=0,
- applies="placement",
- maps_to="global_right_padding",
- description="Global right padding for placement sites",
- range=(0, 100),
- example="8",
- ),
- ParamSchema(
- param="place.cell_padding_x",
- group="place",
- name="cell_padding_x",
- type="int",
- default=300,
- applies="placement",
- maps_to={"dreamplace": "cell_padding_x"},
- description="Cell padding in x-direction in database units",
- range=(0, 10000),
- example="400",
- ),
- ParamSchema(
- param="place.routability_opt",
- group="place",
- name="routability_opt",
- type="int",
- default=1,
- applies="placement",
- maps_to={"dreamplace": "routability_opt_flag"},
- description="Enable routability-driven placement optimization",
- choices=("0", "1"),
- example="1",
- ),
- ParamSchema(
- param="route.bottom_layer",
- group="route",
- name="bottom_layer",
- type="str",
- default="MET2",
- applies="routing",
- maps_to="bottom_layer",
- description="Bottom routing layer",
- choices=("MET1", "MET2", "MET3", "MET4", "MET5"),
- example="MET2",
- ),
- ParamSchema(
- param="route.top_layer",
- group="route",
- name="top_layer",
- type="str",
- default="MET5",
- applies="routing",
- maps_to="top_layer",
- description="Top routing layer",
- choices=("MET2", "MET3", "MET4", "MET5", "MET6"),
- example="MET5",
- ),
- ParamSchema(
- param="sta.max_paths",
- group="sta",
- name="max_paths",
- type="int",
- default=1000,
- applies="sta",
- maps_to="sta_max_paths",
- description="Maximum number of paths in each STA timing report",
- range=(1, 100000),
- example="1000",
- ),
- ParamSchema(
- param="flow.run_analysis",
- group="flow",
- name="run_analysis",
- type="bool",
- default=True,
- applies="all",
- maps_to="run_analysis",
- description="Run per-step analysis (metrics, plots, checklist) after each step",
- example="false",
- ),
-)
+from chipcompiler.data.parameter_schema import * # noqa: F401,F403
+from chipcompiler.data.parameter_schema import validate_schema_type
-PARAM_REGISTRY = _LEGACY_PARAM_REGISTRY + CONFIG_PARAM_SCHEMAS
+_validate_schema_type = validate_schema_type
-_REGISTRY_INDEX: dict[str, ParamSchema] = {s.param: s for s in PARAM_REGISTRY}
-_REQUIRED_FIELDS = (
- "param",
- "group",
- "name",
- "type",
- "default",
- "applies",
- "description",
-)
-
-def lookup_schema(key: str) -> ParamSchema | None:
- return _REGISTRY_INDEX.get(key)
-
-
-def list_schemas() -> tuple[ParamSchema, ...]:
- return PARAM_REGISTRY
-
-
-def list_groups() -> list[str]:
- seen: list[str] = []
- for s in PARAM_REGISTRY:
- if s.group not in seen:
- seen.append(s.group)
- return seen
-
-
-def is_known_key(key: str) -> bool:
- return key in _REGISTRY_INDEX
-
-
-def validate_schema_record(schema: ParamSchema) -> list[str]:
- return [
- f"missing required field: {f}"
- for f in _REQUIRED_FIELDS
- if getattr(schema, f, None) is None or (f != "default" and getattr(schema, f) == "")
- ]
-
-
-# ---------------------------------------------------------------------------
-# Value parsing
-# ---------------------------------------------------------------------------
-
-
-def parse_value(raw: str, schema: ParamSchema) -> object:
- ptype = schema.type
-
- if ptype == "int":
- try:
- return int(raw)
- except ValueError as exc:
- raise ValueError(f"expected int for {schema.param}, got '{raw}'") from exc
-
- if ptype == "float":
- try:
- return float(raw)
- except ValueError as exc:
- raise ValueError(f"expected float for {schema.param}, got '{raw}'") from exc
-
- if ptype == "bool":
- low = raw.lower()
- if low in ("true", "1", "yes"):
- return True
- if low in ("false", "0", "no"):
- return False
- raise ValueError(f"expected bool for {schema.param}, got '{raw}'")
-
- if ptype == "str":
- return raw
-
- if ptype in ("list[int]", "list[float]", "list[str]"):
- try:
- parsed = json.loads(raw)
- except json.JSONDecodeError:
- parsed = None
- if parsed is not None:
- value, error = _validate_schema_type(parsed, schema)
- if error is None:
- return value
- raise ValueError(error)
-
- stripped = raw.strip("[]() ")
- if not stripped:
- return []
- parts = [p.strip() for p in stripped.split(",")]
- if ptype == "list[int]":
- try:
- return [int(p) for p in parts if p]
- except ValueError as exc:
- raise ValueError(f"expected list[int] for {schema.param}, got '{raw}'") from exc
- if ptype == "list[float]":
- try:
- return [float(p) for p in parts if p]
- except ValueError as exc:
- raise ValueError(f"expected list[float] for {schema.param}, got '{raw}'") from exc
- return [p for p in parts if p]
-
- if ptype == "json":
- try:
- return json.loads(raw)
- except json.JSONDecodeError as exc:
- raise ValueError(f"expected JSON for {schema.param}, got '{raw}'") from exc
-
- raise ValueError(f"unsupported type '{ptype}' for {schema.param}")
-
-
-def validate_value(value: object, schema: ParamSchema) -> list[str]:
- errors: list[str] = []
-
- if schema.range is not None:
- lo, hi = schema.range
- if not isinstance(value, (int, float)):
- # Raw layers (e.g. project.json parameters) reach this without
- # the typed parsing --set/[params] get: a string must fail loud
- # instead of silently skipping the range check.
- errors.append(f"value {value!r} is not numeric for {schema.param}")
- elif value < lo or value > hi:
- errors.append(f"value {value} out of range [{lo}, {hi}] for {schema.param}")
-
- if schema.choices is not None:
- str_val = str(value)
- if str_val not in schema.choices:
- errors.append(
- f"value '{str_val}' not in allowed choices {schema.choices} for {schema.param}"
- )
-
- return errors
-
-
-# ---------------------------------------------------------------------------
-# Source-aware resolution
-# ---------------------------------------------------------------------------
-
-
-@dataclass
-class ResolvedParam:
- param: str
- value: object
- default: object
- source: str
- schema: ParamSchema
-
- @property
- def is_explicit(self) -> bool:
- return self.source != "default"
-
-
-def _validate_schema_type(value: object, schema: ParamSchema) -> tuple[object, str | None]:
- ptype = schema.type
- key = schema.param
-
- if ptype == "int":
- if isinstance(value, bool) or not isinstance(value, int):
- return value, f"expected int for {key}, got {type(value).__name__}"
- return value, None
-
- if ptype == "float":
- if isinstance(value, bool):
- return value, f"expected float for {key}, got bool"
- if isinstance(value, (int, float)):
- try:
- return float(value), None
- except OverflowError:
- # A huge JSON integer cannot become a float; that is invalid
- # input, not a crash.
- return value, f"expected float for {key}, value too large to represent"
- return value, f"expected float for {key}, got {type(value).__name__}"
-
- if ptype == "bool":
- if isinstance(value, bool):
- return value, None
- if isinstance(value, str):
- low = value.lower()
- if low in ("true", "1", "yes"):
- return True, None
- if low in ("false", "0", "no"):
- return False, None
- return value, f"expected bool for {key}, got {type(value).__name__}"
-
- if ptype == "str":
- if isinstance(value, str):
- return value, None
- return value, f"expected str for {key}, got {type(value).__name__}"
-
- if ptype == "list[int]":
- if not isinstance(value, list):
- return value, f"expected list for {key}, got {type(value).__name__}"
- for i, v in enumerate(value):
- if isinstance(v, bool) or not isinstance(v, int):
- return value, f"expected list[int] for {key}, element {i} is {type(v).__name__}"
- return value, None
-
- if ptype == "list[float]":
- if not isinstance(value, list):
- return value, f"expected list for {key}, got {type(value).__name__}"
- for i, v in enumerate(value):
- if isinstance(v, bool):
- return value, f"expected list[float] for {key}, element {i} is bool"
- if not isinstance(v, (int, float)):
- return value, f"expected list[float] for {key}, element {i} is {type(v).__name__}"
- try:
- return [float(v) for v in value], None
- except OverflowError:
- return value, f"expected list[float] for {key}, value too large to represent"
-
- if ptype == "list[str]":
- if not isinstance(value, list):
- return value, f"expected list for {key}, got {type(value).__name__}"
- for i, v in enumerate(value):
- if not isinstance(v, str):
- return value, f"expected list[str] for {key}, element {i} is {type(v).__name__}"
- return value, None
-
- if ptype == "json":
- if isinstance(value, (dict, list)):
- return value, None
- return value, f"expected JSON object or array for {key}, got {type(value).__name__}"
-
- return value, None
-
-
-def resolve_parameters(
- toml_overrides: dict[str, object] | None = None,
- cli_overrides: dict[str, object] | None = None,
- manifest_overrides: dict[str, object] | None = None,
-) -> tuple[list[ResolvedParam], list[str]]:
- toml_overrides = toml_overrides or {}
- cli_overrides = cli_overrides or {}
- manifest_overrides = manifest_overrides or {}
- resolved: list[ResolvedParam] = []
- errors: list[str] = []
-
- for schema in PARAM_REGISTRY:
- key = schema.param
- if key in cli_overrides:
- value = cli_overrides[key]
- val_errors = validate_value(value, schema)
- if val_errors:
- errors.extend(val_errors)
- resolved.append(
- ResolvedParam(
- param=key,
- value=value,
- default=schema.default,
- source="cli",
- schema=schema,
- )
- )
- elif key in toml_overrides:
- value = toml_overrides[key]
- value, coerce_err = _validate_schema_type(value, schema)
- if coerce_err:
- errors.append(coerce_err)
- val_errors = validate_value(value, schema)
- if val_errors:
- errors.extend(val_errors)
- resolved.append(
- ResolvedParam(
- param=key,
- value=value,
- default=schema.default,
- source="ecc.toml",
- schema=schema,
- )
- )
- elif key in manifest_overrides:
- value = manifest_overrides[key]
- value, coerce_err = _validate_schema_type(value, schema)
- if coerce_err:
- errors.append(coerce_err)
- val_errors = validate_value(value, schema)
- if val_errors:
- errors.extend(val_errors)
- resolved.append(
- ResolvedParam(
- param=key,
- value=value,
- default=schema.default,
- source="project.json",
- schema=schema,
- )
- )
- else:
- resolved.append(
- ResolvedParam(
- param=key,
- value=schema.default,
- default=schema.default,
- source="default",
- schema=schema,
- )
- )
-
- return resolved, errors
-
-
-# ---------------------------------------------------------------------------
-# Semantic-to-backend mapping
-# ---------------------------------------------------------------------------
-
-
-def manifest_value_for(canonical: dict, maps_to: str | dict | None) -> tuple[object, bool]:
- """Resolve a manifest-layer value via its schema target (presence-keyed).
-
- A string ``maps_to`` is a top-level canonical key; a dict ``maps_to`` is a
- single-level (subtree, leaf) path. Returns (value, present): an explicit
- JSON ``null`` counts as present, an absent key does not.
- """
- if isinstance(maps_to, str):
- if maps_to in canonical:
- return canonical[maps_to], True
- return None, False
- if maps_to is None:
- return None, False
- for subtree, leaf in maps_to.items():
- node = canonical.get(subtree)
- if isinstance(node, dict) and leaf in node:
- return node[leaf], True
- return None, False
-
-
-def coerce_manifest_parameters(
- canonical: dict,
- registry: tuple[ParamSchema, ...] = PARAM_REGISTRY,
- skip_params: frozenset | set = frozenset(),
-) -> tuple[dict, list[str]]:
- """Coerce manifest-layer parameter values to their schema types.
-
- Type check/coercion only — range/choice rules stay check-path concerns.
- Works on a deep copy: coerced values are written back, uncoercible values
- are left as-is (the caller decides what an error means), and keys outside
- the registry pass through untouched (forward-compatible).
- """
- coerced = copy.deepcopy(canonical)
- errors: list[str] = []
- for schema in registry:
- if schema.param in skip_params:
- continue
- value, present = manifest_value_for(coerced, schema.maps_to)
- if not present:
- continue
- new_value, err = _validate_schema_type(value, schema)
- if err:
- errors.append(err)
- continue
- maps_to = schema.maps_to
- if isinstance(maps_to, str):
- coerced[maps_to] = new_value
- elif isinstance(maps_to, dict):
- for subtree, leaf in maps_to.items():
- node = coerced.get(subtree)
- if isinstance(node, dict) and leaf in node:
- node[leaf] = new_value
- break
- return coerced, errors
-
-
-def build_backend_overrides(resolved: list[ResolvedParam]) -> dict:
- overrides: dict = {}
- for rp in resolved:
- if rp.value == rp.default and rp.source == "default":
- continue
- maps_to = rp.schema.maps_to
- value = rp.value
- if isinstance(maps_to, str):
- overrides[maps_to] = value
- elif isinstance(maps_to, dict):
- for parent_key, child_key in maps_to.items():
- if parent_key not in overrides:
- overrides[parent_key] = {}
- overrides[parent_key][child_key] = value
- return overrides
-
-
-def build_config_overrides(resolved: list[ResolvedParam]) -> dict[str, object]:
- overrides: dict[str, object] = {}
- for rp in resolved:
- target = rp.schema.config_target
- if target is None or not rp.is_explicit:
- continue
- config_overrides = overrides.setdefault(target.config_key, {})
- _set_nested_value(config_overrides, target.json_path, rp.value)
- return overrides
-
-
-def build_pdk_overrides(resolved: list[ResolvedParam]) -> dict[str, object]:
- return {
- rp.schema.pdk_target: rp.value
- for rp in resolved
- if rp.schema.pdk_target is not None and rp.is_explicit
- }
-
-
-def _set_nested_value(data: dict, path: tuple[str, ...], value: object) -> None:
- current = data
- for key in path[:-1]:
- child = current.get(key)
- if not isinstance(child, dict):
- child = {}
- current[key] = child
- current = child
- current[path[-1]] = value
-
-
-def parse_cli_overrides(pairs: list[str]) -> tuple[dict[str, object], list[str]]:
- result: dict[str, object] = {}
- errors: list[str] = []
-
- for pair in pairs:
- if "=" not in pair:
- errors.append(f"malformed override: '{pair}' (expected key=value)")
- continue
-
- key, _, raw_value = pair.partition("=")
- key = key.strip()
- raw_value = raw_value.strip()
-
- schema = lookup_schema(key)
- if schema is None:
- errors.append(f"unknown parameter: '{key}'")
- continue
-
- try:
- value = parse_value(raw_value, schema)
- except ValueError as exc:
- errors.append(str(exc))
- continue
-
- val_errors = validate_value(value, schema)
- if val_errors:
- errors.extend(val_errors)
- continue
-
- result[key] = value
-
- return result, errors
-
-
-def validate_pdk_target(schema: ParamSchema, value: object, cfg) -> str | None:
- """Validate a PDK-target value using the existing PDK resolver and checks."""
+def validate_pdk_target(schema, value, cfg) -> str | None:
if schema.pdk_target is None:
return None
-
- from dataclasses import replace
-
from chipcompiler.cli.project.config import (
_validate_pdk_contents,
resolve_pdk_overrides,
resolve_pdk_root,
)
- raw_overrides = dict(cfg.pdk_overrides)
- raw_overrides[schema.pdk_target] = value
- candidate = replace(cfg, pdk_overrides=raw_overrides)
+ overrides = dict(cfg.pdk_overrides)
+ overrides[schema.pdk_target] = value
+ candidate = replace(cfg, pdk_overrides=overrides)
return _validate_pdk_contents(
candidate.pdk_name,
resolve_pdk_root(candidate),
resolve_pdk_overrides(candidate),
)
-
-
-def parse_toml_params(params_table: dict) -> tuple[dict[str, object], list[str]]:
- flat: dict[str, object] = {}
- errors: list[str] = []
-
- def visit(path: tuple[str, ...], value: object) -> None:
- param_key = ".".join(path)
- schema = lookup_schema(param_key)
- if schema is not None:
- try:
- if isinstance(value, str):
- parsed = parse_value(value, schema)
- else:
- parsed, type_err = _validate_schema_type(value, schema)
- if type_err:
- errors.append(type_err)
- return
- except ValueError as exc:
- errors.append(str(exc))
- return
-
- val_errors = validate_value(parsed, schema)
- if val_errors:
- errors.extend(val_errors)
- return
- flat[param_key] = parsed
- return
-
- if isinstance(value, dict):
- for key, child in value.items():
- visit((*path, key), child)
- return
-
- errors.append(f"unknown parameter in ecc.toml: '{param_key}'")
-
- for group_key, group_val in params_table.items():
- if not isinstance(group_val, dict):
- errors.append(f"[params.{group_key}] must be a table, got {type(group_val).__name__}")
- continue
- visit((group_key,), group_val)
-
- return flat, errors
diff --git a/chipcompiler/cli/project/workspace_params.py b/chipcompiler/cli/project/workspace_params.py
index d6eb117d..da1e397f 100644
--- a/chipcompiler/cli/project/workspace_params.py
+++ b/chipcompiler/cli/project/workspace_params.py
@@ -1,142 +1,3 @@
-"""Workspace-local parameter persistence and replay helpers."""
+"""CLI compatibility facade for workspace parameter persistence."""
-from copy import deepcopy
-
-from chipcompiler.cli.project.params import (
- ResolvedParam,
- build_backend_overrides,
- build_config_overrides,
-)
-from chipcompiler.data.parameter import update_parameters
-from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY
-from chipcompiler.utility import json_read
-
-WORKSPACE_PARAM_OVERRIDES_KEY = "workspace_param_overrides"
-
-_APPLIES_TO_STEP = {
- "synthesis": "Synthesis",
- "floorplan": "Floorplan",
- "placement": "place",
- "cts": "CTS",
- "routing": "route",
- "filler": "filler",
- "rcx": "RCX",
- "sta": "sta",
-}
-_MISSING = object()
-
-
-def workspace_param_step(schema) -> str:
- step = _APPLIES_TO_STEP.get(schema.applies)
- if step is None:
- raise ValueError(f"{schema.param} requires a full workspace refresh")
- return step
-
-
-def workspace_param_value(workspace, schema) -> object:
- if schema.pdk_target is not None:
- raise ValueError(f"{schema.param} requires a full workspace refresh")
- if schema.maps_to is not None:
- return _parameter_target_value(workspace.parameters.data, schema.maps_to, schema.default)
- if schema.config_target is not None:
- config_path = workspace.config.get(schema.config_target.config_key)
- if config_path is None:
- raise ValueError(f"workspace config missing target: {schema.config_target.config_key}")
- value = _nested_value(json_read(config_path), schema.config_target.json_path)
- return schema.default if value is _MISSING else value
- raise ValueError(f"{schema.param} has no workspace configuration target")
-
-
-def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]:
- baseline = update_workspace_param_value(workspace, schema, value)
- return baseline, workspace_param_step(schema)
-
-
-def update_workspace_param_value(workspace, schema, value: object) -> object:
- """Apply and track one public parameter without choosing invalidation scope."""
- records = _override_records(workspace.parameters.data)
- record = next((item for item in records if item["key"] == schema.param), None)
- if record is None:
- record = {
- "key": schema.param,
- "baseline": deepcopy(workspace_param_value(workspace, schema)),
- }
- records.append(record)
- record["value"] = deepcopy(value)
- _set_override_records(workspace.parameters.data, records)
- _apply_workspace_value(workspace, schema, value)
- return record["baseline"]
-
-
-def unset_workspace_param(workspace, schema) -> tuple[object, str] | None:
- records = _override_records(workspace.parameters.data)
- record = next((item for item in records if item["key"] == schema.param), None)
- if record is None:
- return None
- records.remove(record)
- _set_override_records(workspace.parameters.data, records)
- _apply_workspace_value(workspace, schema, record["baseline"])
- return record["baseline"], workspace_param_step(schema)
-
-
-def workspace_param_diff(workspace) -> list[dict]:
- return _override_records(workspace.parameters.data)
-
-
-def _apply_workspace_value(workspace, schema, value: object) -> None:
- resolved = ResolvedParam(
- param=schema.param,
- value=value,
- default=schema.default,
- source="workspace",
- schema=schema,
- )
- update_parameters(build_backend_overrides([resolved]), workspace.parameters.data)
- config_overrides = build_config_overrides([resolved])
- if config_overrides:
- update_parameters({CONFIG_OVERRIDES_KEY: config_overrides}, workspace.parameters.data)
-
-
-def _override_records(parameters: dict) -> list[dict]:
- raw = parameters.get(WORKSPACE_PARAM_OVERRIDES_KEY, [])
- if not isinstance(raw, list):
- return []
- records = []
- for item in raw:
- if not isinstance(item, dict):
- continue
- key = item.get("key")
- if not isinstance(key, str) or "baseline" not in item:
- continue
- records.append(deepcopy(item))
- return records
-
-
-def _set_override_records(parameters: dict, records: list[dict]) -> None:
- if records:
- parameters[WORKSPACE_PARAM_OVERRIDES_KEY] = records
- else:
- parameters.pop(WORKSPACE_PARAM_OVERRIDES_KEY, None)
-
-
-def _parameter_target_value(parameters: dict, target, default: object) -> object:
- if isinstance(target, str):
- return deepcopy(parameters.get(target, default))
- if isinstance(target, dict) and len(target) == 1:
- parent, child = next(iter(target.items()))
- nested = parameters.get(parent)
- if isinstance(nested, dict):
- return deepcopy(nested.get(child, default))
- return deepcopy(default)
-
-
-def _nested_value(data: object, path: tuple[str, ...]) -> object:
- current = data
- for key in path:
- if not isinstance(current, dict):
- return _MISSING
- payload: dict = dict(current)
- if key not in payload:
- return _MISSING
- current = payload[key]
- return deepcopy(current)
+from chipcompiler.data.workspace_parameters import * # noqa: F401,F403
diff --git a/chipcompiler/data/parameter_schema.py b/chipcompiler/data/parameter_schema.py
new file mode 100644
index 00000000..45bd1a53
--- /dev/null
+++ b/chipcompiler/data/parameter_schema.py
@@ -0,0 +1,491 @@
+"""Canonical public parameter catalog and schema operations.
+
+The catalog is shared by the headless Engine, CLI, and Studio adapter. CLI
+specific PDK path validation remains in ``chipcompiler.cli.project.params``.
+"""
+
+import copy
+import json
+from dataclasses import dataclass
+
+from chipcompiler.data.config_params import CONFIG_PARAM_SCHEMAS
+from chipcompiler.data.config_params.common import ParamSchema
+
+_LEGACY_PARAM_REGISTRY: tuple[ParamSchema, ...] = (
+ ParamSchema(
+ "design.frequency_mhz",
+ "design",
+ "frequency_mhz",
+ "float",
+ 100.0,
+ "synthesis",
+ "Target clock frequency in MHz",
+ "frequency_max",
+ range=(1e-6, 10000.0),
+ unit="MHz",
+ example="200.0",
+ ),
+ ParamSchema(
+ "floorplan.core_util",
+ "floorplan",
+ "core_util",
+ "float",
+ 0.4,
+ "floorplan",
+ "Core utilization ratio",
+ {"core": "utilitization"},
+ range=(0.01, 1.0),
+ example="0.45",
+ ),
+ ParamSchema(
+ "floorplan.core_margin",
+ "floorplan",
+ "core_margin",
+ "list[int]",
+ [2, 2],
+ "floorplan",
+ "Core margin in micrometers [horizontal, vertical]",
+ {"core": "margin"},
+ example="[2, 2]",
+ ),
+ ParamSchema(
+ "floorplan.aspect_ratio",
+ "floorplan",
+ "aspect_ratio",
+ "float",
+ 1.0,
+ "floorplan",
+ "Core aspect ratio (width/height)",
+ {"core": "aspect_ratio"},
+ range=(0.1, 10.0),
+ example="1.0",
+ ),
+ ParamSchema(
+ "cts.max_fanout",
+ "cts",
+ "max_fanout",
+ "int",
+ 20,
+ "cts",
+ "Maximum fanout for clock tree synthesis",
+ "max_fanout",
+ range=(1, 200),
+ example="16",
+ ),
+ ParamSchema(
+ "place.target_density",
+ "place",
+ "target_density",
+ "float",
+ 0.2,
+ "placement",
+ "Target placement density",
+ {"dreamplace": "target_density"},
+ range=(0.1, 0.95),
+ example="0.65",
+ ),
+ ParamSchema(
+ "place.target_overflow",
+ "place",
+ "target_overflow",
+ "float",
+ 0.1,
+ "placement",
+ "Target overflow for global placement",
+ {"dreamplace": "stop_overflow"},
+ range=(0.0, 1.0),
+ example="0.08",
+ ),
+ ParamSchema(
+ "place.global_right_padding",
+ "place",
+ "global_right_padding",
+ "int",
+ 0,
+ "placement",
+ "Global right padding for placement sites",
+ "global_right_padding",
+ range=(0, 100),
+ example="8",
+ ),
+ ParamSchema(
+ "place.cell_padding_x",
+ "place",
+ "cell_padding_x",
+ "int",
+ 300,
+ "placement",
+ "Cell padding in x-direction in database units",
+ {"dreamplace": "cell_padding_x"},
+ range=(0, 10000),
+ example="400",
+ ),
+ ParamSchema(
+ "place.routability_opt",
+ "place",
+ "routability_opt",
+ "int",
+ 1,
+ "placement",
+ "Enable routability-driven placement optimization",
+ {"dreamplace": "routability_opt_flag"},
+ choices=("0", "1"),
+ example="1",
+ ),
+ ParamSchema(
+ "route.bottom_layer",
+ "route",
+ "bottom_layer",
+ "str",
+ "MET2",
+ "routing",
+ "Bottom routing layer",
+ "bottom_layer",
+ choices=("MET1", "MET2", "MET3", "MET4", "MET5"),
+ example="MET2",
+ ),
+ ParamSchema(
+ "route.top_layer",
+ "route",
+ "top_layer",
+ "str",
+ "MET5",
+ "routing",
+ "Top routing layer",
+ "top_layer",
+ choices=("MET2", "MET3", "MET4", "MET5", "MET6"),
+ example="MET5",
+ ),
+ ParamSchema(
+ "sta.max_paths",
+ "sta",
+ "max_paths",
+ "int",
+ 1000,
+ "sta",
+ "Maximum number of paths in each STA timing report",
+ "sta_max_paths",
+ range=(1, 100000),
+ example="1000",
+ ),
+ ParamSchema(
+ param="flow.run_analysis",
+ group="flow",
+ name="run_analysis",
+ type="bool",
+ default=True,
+ applies="all",
+ description="Run per-step analysis (metrics, plots, checklist) after each step",
+ maps_to="run_analysis",
+ example="false",
+ ),
+)
+
+PARAM_REGISTRY = _LEGACY_PARAM_REGISTRY + CONFIG_PARAM_SCHEMAS
+_REGISTRY_INDEX = {schema.param: schema for schema in PARAM_REGISTRY}
+_REQUIRED_FIELDS = ("param", "group", "name", "type", "default", "applies", "description")
+
+
+def lookup_schema(key: str) -> ParamSchema | None:
+ return _REGISTRY_INDEX.get(key)
+
+
+def list_schemas() -> tuple[ParamSchema, ...]:
+ return PARAM_REGISTRY
+
+
+def list_groups() -> list[str]:
+ return list(dict.fromkeys(schema.group for schema in PARAM_REGISTRY))
+
+
+def is_known_key(key: str) -> bool:
+ return key in _REGISTRY_INDEX
+
+
+def validate_schema_record(schema: ParamSchema) -> list[str]:
+ return [
+ f"missing required field: {field}"
+ for field in _REQUIRED_FIELDS
+ if getattr(schema, field, None) is None
+ or (field != "default" and getattr(schema, field) == "")
+ ]
+
+
+def validate_schema_type(value: object, schema: ParamSchema) -> tuple[object, str | None]:
+ key, ptype = schema.param, schema.type
+ if ptype == "int":
+ return (
+ (value, None)
+ if isinstance(value, int) and not isinstance(value, bool)
+ else (value, f"expected int for {key}, got {type(value).__name__}")
+ )
+ if ptype == "float":
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return value, f"expected float for {key}, got {type(value).__name__}"
+ try:
+ return float(value), None
+ except OverflowError:
+ return value, f"expected float for {key}, value too large to represent"
+ if ptype == "bool":
+ if isinstance(value, bool):
+ return value, None
+ if isinstance(value, str) and value.lower() in {"true", "1", "yes", "false", "0", "no"}:
+ return value.lower() in {"true", "1", "yes"}, None
+ return value, f"expected bool for {key}, got {type(value).__name__}"
+ if ptype == "str":
+ return (
+ (value, None)
+ if isinstance(value, str)
+ else (value, f"expected str for {key}, got {type(value).__name__}")
+ )
+ if ptype.startswith("list["):
+ if not isinstance(value, list):
+ return value, f"expected list for {key}, got {type(value).__name__}"
+ element_type = ptype[5:-1]
+ for index, item in enumerate(value):
+ if element_type == "str":
+ valid = isinstance(item, str)
+ elif element_type == "int":
+ valid = isinstance(item, int) and not isinstance(item, bool)
+ else:
+ valid = isinstance(item, (int, float)) and not isinstance(item, bool)
+ if not valid:
+ return (
+ value,
+ f"expected {ptype} for {key}, element {index} is {type(item).__name__}",
+ )
+ if element_type == "float":
+ try:
+ return [float(item) for item in value], None
+ except OverflowError:
+ return value, f"expected list[float] for {key}, value too large to represent"
+ return value, None
+ if ptype == "json":
+ return (
+ (value, None)
+ if isinstance(value, (dict, list))
+ else (value, f"expected JSON object or array for {key}, got {type(value).__name__}")
+ )
+ return value, None
+
+
+def parse_value(raw: str, schema: ParamSchema) -> object:
+ if schema.type in {"int", "float"}:
+ try:
+ return int(raw) if schema.type == "int" else float(raw)
+ except ValueError as exc:
+ raise ValueError(f"expected {schema.type} for {schema.param}, got '{raw}'") from exc
+ if schema.type == "bool":
+ low = raw.lower()
+ if low in {"true", "1", "yes"}:
+ return True
+ if low in {"false", "0", "no"}:
+ return False
+ raise ValueError(f"expected bool for {schema.param}, got '{raw}'")
+ if schema.type == "str":
+ return raw
+ if schema.type.startswith("list["):
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ parts = [part.strip() for part in raw.strip("[]() ").split(",") if part.strip()]
+ element_type = schema.type[5:-1]
+ try:
+ parsed = (
+ [int(part) for part in parts]
+ if element_type == "int"
+ else [float(part) for part in parts]
+ if element_type == "float"
+ else parts
+ )
+ except ValueError as exc:
+ raise ValueError(f"expected {schema.type} for {schema.param}, got '{raw}'") from exc
+ value, error = validate_schema_type(parsed, schema)
+ if error:
+ raise ValueError(error)
+ return value
+ if schema.type == "json":
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"expected JSON for {schema.param}, got '{raw}'") from exc
+ raise ValueError(f"unsupported type '{schema.type}' for {schema.param}")
+
+
+def validate_value(value: object, schema: ParamSchema) -> list[str]:
+ errors = []
+ if schema.range is not None:
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
+ errors.append(f"value {value!r} is not numeric for {schema.param}")
+ elif value < schema.range[0] or value > schema.range[1]:
+ errors.append(
+ f"value {value} out of range [{schema.range[0]}, {schema.range[1]}] "
+ f"for {schema.param}"
+ )
+ if schema.choices is not None and str(value) not in schema.choices:
+ errors.append(f"value '{value}' not in allowed choices {schema.choices} for {schema.param}")
+ return errors
+
+
+@dataclass
+class ResolvedParam:
+ param: str
+ value: object
+ default: object
+ source: str
+ schema: ParamSchema
+
+ @property
+ def is_explicit(self) -> bool:
+ return self.source != "default"
+
+
+def resolve_parameters(toml_overrides=None, cli_overrides=None, manifest_overrides=None):
+ layers = (
+ (cli_overrides or {}, "cli"),
+ (toml_overrides or {}, "ecc.toml"),
+ (manifest_overrides or {}, "project.json"),
+ )
+ resolved, errors = [], []
+ for schema in PARAM_REGISTRY:
+ value, source = schema.default, "default"
+ for layer, layer_source in layers:
+ if schema.param not in layer:
+ continue
+ value, source = layer[schema.param], layer_source
+ if source != "cli":
+ value, type_error = validate_schema_type(value, schema)
+ if type_error:
+ errors.append(type_error)
+ errors.extend(validate_value(value, schema))
+ break
+ resolved.append(ResolvedParam(schema.param, value, schema.default, source, schema))
+ return resolved, errors
+
+
+def manifest_value_for(canonical: dict, maps_to) -> tuple[object, bool]:
+ if isinstance(maps_to, str):
+ return (canonical[maps_to], True) if maps_to in canonical else (None, False)
+ if isinstance(maps_to, dict):
+ for parent, child in maps_to.items():
+ node = canonical.get(parent)
+ if isinstance(node, dict) and child in node:
+ return node[child], True
+ return None, False
+
+
+def coerce_manifest_parameters(canonical: dict, registry=PARAM_REGISTRY, skip_params=frozenset()):
+ coerced, errors = copy.deepcopy(canonical), []
+ for schema in registry:
+ if schema.param in skip_params:
+ continue
+ value, present = manifest_value_for(coerced, schema.maps_to)
+ if not present:
+ continue
+ value, error = validate_schema_type(value, schema)
+ if error:
+ errors.append(error)
+ continue
+ if isinstance(schema.maps_to, str):
+ coerced[schema.maps_to] = value
+ else:
+ for parent, child in schema.maps_to.items():
+ if isinstance(coerced.get(parent), dict) and child in coerced[parent]:
+ coerced[parent][child] = value
+ break
+ return coerced, errors
+
+
+def build_backend_overrides(resolved):
+ overrides = {}
+ for item in resolved:
+ if item.source == "default" and item.value == item.default:
+ continue
+ target, value = item.schema.maps_to, item.value
+ if isinstance(target, str):
+ overrides[target] = value
+ elif isinstance(target, dict):
+ for parent, child in target.items():
+ overrides.setdefault(parent, {})[child] = value
+ return overrides
+
+
+def build_config_overrides(resolved):
+ overrides = {}
+ for item in resolved:
+ target = item.schema.config_target
+ if target is None or not item.is_explicit:
+ continue
+ current = overrides.setdefault(target.config_key, {})
+ for key in target.json_path[:-1]:
+ current = current.setdefault(key, {})
+ current[target.json_path[-1]] = item.value
+ return overrides
+
+
+def build_pdk_overrides(resolved):
+ return {
+ item.schema.pdk_target: item.value
+ for item in resolved
+ if item.schema.pdk_target and item.is_explicit
+ }
+
+
+def parse_cli_overrides(pairs):
+ result, errors = {}, []
+ for pair in pairs:
+ if "=" not in pair:
+ errors.append(f"malformed override: '{pair}' (expected key=value)")
+ continue
+ key, raw = (part.strip() for part in pair.split("=", 1))
+ schema = lookup_schema(key)
+ if schema is None:
+ errors.append(f"unknown parameter: '{key}'")
+ continue
+ try:
+ value = parse_value(raw, schema)
+ except ValueError as exc:
+ errors.append(str(exc))
+ continue
+ value_errors = validate_value(value, schema)
+ if value_errors:
+ errors.extend(value_errors)
+ continue
+ result[key] = value
+ return result, errors
+
+
+def parse_toml_params(params_table):
+ flat, errors = {}, []
+
+ def visit(path, value):
+ key = ".".join(path)
+ schema = lookup_schema(key)
+ if schema:
+ try:
+ parsed = parse_value(value, schema) if isinstance(value, str) else value
+ parsed, type_error = validate_schema_type(parsed, schema)
+ if type_error:
+ errors.append(type_error)
+ return
+ except ValueError as exc:
+ errors.append(str(exc))
+ return
+ value_errors = validate_value(parsed, schema)
+ if value_errors:
+ errors.extend(value_errors)
+ return
+ flat[key] = parsed
+ return
+ if isinstance(value, dict):
+ for child, child_value in value.items():
+ visit((*path, child), child_value)
+ else:
+ errors.append(f"unknown parameter in ecc.toml: '{key}'")
+
+ for group, value in params_table.items():
+ if isinstance(value, dict):
+ visit((group,), value)
+ else:
+ errors.append(f"[params.{group}] must be a table, got {type(value).__name__}")
+ return flat, errors
diff --git a/chipcompiler/data/workspace_parameters.py b/chipcompiler/data/workspace_parameters.py
new file mode 100644
index 00000000..8beee995
--- /dev/null
+++ b/chipcompiler/data/workspace_parameters.py
@@ -0,0 +1,127 @@
+"""Workspace-local parameter persistence shared by Engine and CLI."""
+
+from copy import deepcopy
+
+from chipcompiler.data.parameter import update_parameters
+from chipcompiler.data.parameter_schema import (
+ ResolvedParam,
+ build_backend_overrides,
+ build_config_overrides,
+)
+from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY
+from chipcompiler.utility import json_read
+
+WORKSPACE_PARAM_OVERRIDES_KEY = "workspace_param_overrides"
+_APPLIES_TO_STEP = {
+ "synthesis": "Synthesis",
+ "floorplan": "Floorplan",
+ "placement": "place",
+ "cts": "CTS",
+ "routing": "route",
+ "filler": "filler",
+ "rcx": "RCX",
+ "sta": "sta",
+}
+_MISSING = object()
+
+
+def workspace_param_step(schema) -> str:
+ step = _APPLIES_TO_STEP.get(schema.applies)
+ if step is None:
+ raise ValueError(f"{schema.param} requires a full workspace refresh")
+ return step
+
+
+def workspace_param_value(workspace, schema) -> object:
+ if schema.pdk_target is not None:
+ raise ValueError(f"{schema.param} requires a full workspace refresh")
+ if schema.maps_to is not None:
+ return _parameter_target_value(workspace.parameters.data, schema.maps_to, schema.default)
+ if schema.config_target is not None:
+ config_path = workspace.config.get(schema.config_target.config_key)
+ if config_path is None:
+ raise ValueError(f"workspace config missing target: {schema.config_target.config_key}")
+ value = _nested_value(json_read(config_path), schema.config_target.json_path)
+ return schema.default if value is _MISSING else value
+ raise ValueError(f"{schema.param} has no workspace configuration target")
+
+
+def set_workspace_param(workspace, schema, value: object) -> tuple[object, str]:
+ baseline = update_workspace_param_value(workspace, schema, value)
+ return baseline, workspace_param_step(schema)
+
+
+def update_workspace_param_value(workspace, schema, value: object) -> object:
+ records = _override_records(workspace.parameters.data)
+ record = next((item for item in records if item["key"] == schema.param), None)
+ if record is None:
+ record = {
+ "key": schema.param,
+ "baseline": deepcopy(workspace_param_value(workspace, schema)),
+ }
+ records.append(record)
+ record["value"] = deepcopy(value)
+ _set_override_records(workspace.parameters.data, records)
+ _apply_workspace_value(workspace, schema, value)
+ return record["baseline"]
+
+
+def unset_workspace_param(workspace, schema) -> tuple[object, str] | None:
+ records = _override_records(workspace.parameters.data)
+ record = next((item for item in records if item["key"] == schema.param), None)
+ if record is None:
+ return None
+ records.remove(record)
+ _set_override_records(workspace.parameters.data, records)
+ _apply_workspace_value(workspace, schema, record["baseline"])
+ return record["baseline"], workspace_param_step(schema)
+
+
+def workspace_param_diff(workspace) -> list[dict]:
+ return _override_records(workspace.parameters.data)
+
+
+def _apply_workspace_value(workspace, schema, value: object) -> None:
+ resolved = ResolvedParam(schema.param, value, schema.default, "workspace", schema)
+ update_parameters(build_backend_overrides([resolved]), workspace.parameters.data)
+ config_overrides = build_config_overrides([resolved])
+ if config_overrides:
+ update_parameters({CONFIG_OVERRIDES_KEY: config_overrides}, workspace.parameters.data)
+
+
+def _override_records(parameters: dict) -> list[dict]:
+ raw = parameters.get(WORKSPACE_PARAM_OVERRIDES_KEY, [])
+ if not isinstance(raw, list):
+ return []
+ return [
+ deepcopy(item)
+ for item in raw
+ if isinstance(item, dict) and isinstance(item.get("key"), str) and "baseline" in item
+ ]
+
+
+def _set_override_records(parameters: dict, records: list[dict]) -> None:
+ if records:
+ parameters[WORKSPACE_PARAM_OVERRIDES_KEY] = records
+ else:
+ parameters.pop(WORKSPACE_PARAM_OVERRIDES_KEY, None)
+
+
+def _parameter_target_value(parameters: dict, target, default: object) -> object:
+ if isinstance(target, str):
+ return deepcopy(parameters.get(target, default))
+ if isinstance(target, dict) and len(target) == 1:
+ parent, child = next(iter(target.items()))
+ nested = parameters.get(parent)
+ if isinstance(nested, dict):
+ return deepcopy(nested.get(child, default))
+ return deepcopy(default)
+
+
+def _nested_value(data: object, path: tuple[str, ...]) -> object:
+ current = data
+ for key in path:
+ if not isinstance(current, dict) or key not in current:
+ return _MISSING
+ current = current[key]
+ return deepcopy(current)
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
index dcd68470..10d3de6d 100644
--- a/chipcompiler/engine/workspace_configuration.py
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -4,25 +4,27 @@
from pathlib import Path
from typing import Any
-from chipcompiler.cli.project.params import (
- _validate_schema_type,
+from chipcompiler.data import load_workspace, save_parameter
+from chipcompiler.data.parameter_schema import (
list_schemas,
lookup_schema,
+ validate_schema_type,
validate_value,
)
-from chipcompiler.cli.project.workspace_params import (
+from chipcompiler.data.workspace import workspace_config_paths
+from chipcompiler.data.workspace_parameters import (
update_workspace_param_value,
workspace_param_value,
)
-from chipcompiler.data import load_workspace, save_parameter
-from chipcompiler.data.workspace import workspace_config_paths
from chipcompiler.engine.flow import EngineFlow
from chipcompiler.engine.rerun import invalidate_from
from chipcompiler.engine.snapshot import (
SNAPSHOT_FILENAME,
STALE_SNAPSHOT_FILENAME,
+ EngineeringSnapshotError,
ensure_engineering_snapshot,
invalidate_engineering_snapshot,
+ read_engineering_snapshot,
)
from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step
@@ -45,18 +47,21 @@ def update_workspace_configuration(
target = Path(target_directory).expanduser().resolve()
if not target.is_dir():
raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
- before = _snapshot_files(_workspace_file_paths(target))
- try:
- return _update_workspace_configuration(
- target,
- expected_workspace_revision,
- configuration,
- workspace_bindings,
- command_id,
- )
- except BaseException:
- _restore_files(before)
- raise
+ from chipcompiler.engine.reconcile import _workspace_lock
+
+ with _workspace_lock(target):
+ before = _snapshot_files(_workspace_file_paths(target))
+ try:
+ return _update_workspace_configuration(
+ target,
+ expected_workspace_revision,
+ configuration,
+ workspace_bindings,
+ command_id,
+ )
+ except BaseException:
+ _restore_files(before)
+ raise
def _update_workspace_configuration(
@@ -108,7 +113,7 @@ def _update_workspace_configuration(
schema = lookup_schema(parameter)
if schema is None or schema.pdk_target is not None:
raise WorkspaceLifecycleError("unknown_parameter", f"Unknown parameter: {parameter}")
- normalized, type_error = _validate_schema_type(value, schema)
+ normalized, type_error = validate_schema_type(value, schema)
errors = [type_error] if type_error else validate_value(normalized, schema)
if errors:
raise WorkspaceLifecycleError("invalid_parameter", str(errors[0]))
@@ -164,7 +169,14 @@ def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
if flow_names:
flow.update({"fromStepId": flow_names[0], "throughStepId": flow_names[-1]})
inputs, input_bindings = _workspace_inputs(workspace)
+ pdk_mode = "manual" if workspace.parameters.data.get("pdk_config") else "default"
+ pdk_files, pdk_file_bindings = (
+ _workspace_pdk_files(workspace) if pdk_mode == "manual" else ([], {})
+ )
+ snapshot = _read_snapshot_metadata(workspace)
return {
+ "workspaceId": snapshot["workspaceId"],
+ "workspaceRevision": snapshot["workspaceRevision"],
"workspaceSpec": {
"schemaVersion": 1,
"design": {
@@ -172,12 +184,15 @@ def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
"topModule": workspace.design.top_module,
"clockPort": str(workspace.parameters.data.get("clock", "")),
},
- "inputMode": "postSynthesis" if workspace.design.origin_def is not None else "rtl",
+ "inputMode": workspace.parameters.data.get("_input_mode")
+ if workspace.parameters.data.get("_input_mode") in {"rtl", "postSynthesis"}
+ else ("postSynthesis" if workspace.design.origin_def is not None else "rtl"),
"inputs": inputs,
"pdk": {
"familyId": workspace.pdk.name,
"version": workspace.pdk.version,
- "mode": "default",
+ "mode": pdk_mode,
+ **({"files": pdk_files} if pdk_files else {}),
},
"flow": flow,
"parameters": parameters,
@@ -187,6 +202,7 @@ def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
"pdk": {
"root": str(workspace.pdk.root or ""),
"version": workspace.pdk.version,
+ **({"files": pdk_file_bindings} if pdk_file_bindings else {}),
},
},
}
@@ -194,7 +210,7 @@ def read_workspace_configuration(workspace: Any) -> dict[str, Any]:
def read_step_configuration(workspace: Any, step_id: str) -> dict[str, Any]:
step, schemas = _step_catalog(workspace, step_id)
- snapshot = ensure_engineering_snapshot(workspace)
+ snapshot = _read_snapshot_metadata(workspace)
if not schemas:
raise WorkspaceLifecycleError(
"step_configuration_unavailable",
@@ -231,18 +247,21 @@ def update_workspace_step_configuration(
target = Path(target_directory).expanduser().resolve()
if not target.is_dir():
raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
- before = _snapshot_files(_workspace_file_paths(target))
- try:
- return _update_workspace_step_configuration(
- target,
- expected_workspace_revision,
- step_id,
- parameters,
- command_id,
- )
- except BaseException:
- _restore_files(before)
- raise
+ from chipcompiler.engine.reconcile import _workspace_lock
+
+ with _workspace_lock(target):
+ before = _snapshot_files(_workspace_file_paths(target))
+ try:
+ return _update_workspace_step_configuration(
+ target,
+ expected_workspace_revision,
+ step_id,
+ parameters,
+ command_id,
+ )
+ except BaseException:
+ _restore_files(before)
+ raise
def _update_workspace_step_configuration(
@@ -289,7 +308,7 @@ def _update_workspace_step_configuration(
"parameter_not_applicable",
f"Parameter {parameter} is not configurable at {step}",
)
- normalized, type_error = _validate_schema_type(value, schema)
+ normalized, type_error = validate_schema_type(value, schema)
errors = [type_error] if type_error else validate_value(normalized, schema)
if errors:
raise WorkspaceLifecycleError("invalid_parameter", str(errors[0]))
@@ -378,7 +397,10 @@ def read_workspace_configuration_from_directory(
def _workspace_inputs(workspace: Any) -> tuple[list[dict[str, str]], dict[str, str]]:
values = []
- if workspace.design.input_filelist is not None:
+ input_mode = workspace.parameters.data.get("_input_mode")
+ if input_mode == "postSynthesis" and workspace.design.origin_verilog is not None:
+ values.append(("netlist", workspace.design.origin_verilog))
+ elif workspace.design.input_filelist is not None:
values.append(("filelist", workspace.design.input_filelist))
elif workspace.design.origin_verilog is not None:
role = "netlist" if workspace.design.origin_def is not None else "rtl"
@@ -397,11 +419,45 @@ def _workspace_inputs(workspace: Any) -> tuple[list[dict[str, str]], dict[str, s
)
+def _workspace_pdk_files(workspace: Any) -> tuple[list[dict[str, str]], dict[str, str]]:
+ grouped = {
+ "tech": [workspace.pdk.tech] if workspace.pdk.tech else [],
+ "lef": list(workspace.pdk.lefs),
+ "liberty": list(workspace.pdk.libs),
+ "mapping": [workspace.pdk.mapping_file] if workspace.pdk.mapping_file else [],
+ }
+ refs = []
+ bindings = {}
+ for role, paths in grouped.items():
+ for index, path in enumerate(paths):
+ file_id = role if len(paths) == 1 else f"{role}-{index}"
+ refs.append({"fileId": file_id, "role": role})
+ bindings[file_id] = str(path)
+ return refs, bindings
+
+
+def _read_snapshot_metadata(workspace: Any) -> dict[str, Any]:
+ try:
+ snapshot = read_engineering_snapshot(workspace)
+ except (EngineeringSnapshotError, OSError):
+ return {"workspaceId": None, "workspaceRevision": None}
+ return {
+ "workspaceId": snapshot["workspaceId"],
+ "workspaceRevision": snapshot["workspaceRevision"],
+ }
+
+
def _flow_id(names: list[str]) -> str:
for flow_id, builder in get_flow_builders().items():
candidate = [str(getattr(step, "value", step)) for step, _tool, _state in builder()]
if candidate == names:
return flow_id
+ if flow_id == "rtl2gds" and names:
+ start = next(
+ (index for index, value in enumerate(candidate) if value == names[0]), None
+ )
+ if start is not None and candidate[start : start + len(names)] == names:
+ return flow_id
return "custom"
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index 97c826e0..e4db911c 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -8,8 +8,8 @@
from pathlib import Path
from typing import Any
-from chipcompiler.cli.project.params import build_backend_overrides, resolve_parameters
from chipcompiler.data import PDK, create_workspace, get_pdk, load_workspace
+from chipcompiler.data.parameter_schema import build_backend_overrides, resolve_parameters
from chipcompiler.engine.snapshot import create_engineering_snapshot
from chipcompiler.engine.workspace_spec import validate_workspace_spec
from chipcompiler.rtl2gds import get_flow_builders
@@ -82,6 +82,20 @@ def create_workspace_from_spec(
spec: object,
bindings: object,
command_id: str = "",
+):
+ """Create a Workspace Spec target while serializing sibling creators."""
+ from chipcompiler.engine.reconcile import _workspace_lock
+
+ target = Path(target_directory).expanduser().resolve()
+ with _workspace_lock(target):
+ return _create_workspace_from_spec(target, spec, bindings, command_id)
+
+
+def _create_workspace_from_spec(
+ target_directory: str | Path,
+ spec: object,
+ bindings: object,
+ command_id: str = "",
):
target = Path(target_directory).expanduser().resolve()
fingerprint = _workspace_command_fingerprint("create", spec, bindings)
@@ -122,9 +136,11 @@ def create_workspace_from_spec(
}
)
flow_steps = get_flow_builders()[resolved["flow"]["flowId"]]()
+ flow_start = resolved["flow"].get("fromStepId")
+ flow_end = resolved["flow"].get("throughStepId")
flow_config = {
- "start_step": str(getattr(flow_steps[0][0], "value", flow_steps[0][0])),
- "end_step": str(getattr(flow_steps[-1][0], "value", flow_steps[-1][0])),
+ "start_step": flow_start or str(getattr(flow_steps[0][0], "value", flow_steps[0][0])),
+ "end_step": flow_end or str(getattr(flow_steps[-1][0], "value", flow_steps[-1][0])),
}
pdk, pdk_root, pdk_overrides = _bound_pdk(
resolved["pdk"], _string_keyed_dict(binding_map["pdk"])
@@ -167,6 +183,11 @@ def create_workspace_from_spec(
raise WorkspaceLifecycleError(
"workspace_create_failed", f"Workspace creation failed: {target}"
)
+ workspace.parameters.data["_input_mode"] = resolved["inputMode"]
+ from chipcompiler.data import save_parameter
+
+ if not save_parameter(workspace.parameters):
+ raise OSError(f"Failed to persist input mode: {workspace.parameters.path}")
snapshot = create_engineering_snapshot(workspace)
_write_workspace_command(
target,
@@ -192,6 +213,24 @@ def update_workspace_from_spec(
spec: object,
bindings: object,
command_id: str = "",
+):
+ target = Path(target_directory).expanduser().resolve()
+ if not target.is_dir():
+ raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
+ from chipcompiler.engine.reconcile import _workspace_lock
+
+ with _workspace_lock(target):
+ return _update_workspace_from_spec(
+ target, expected_workspace_revision, spec, bindings, command_id
+ )
+
+
+def _update_workspace_from_spec(
+ target_directory: str | Path,
+ expected_workspace_revision: int,
+ spec: object,
+ bindings: object,
+ command_id: str = "",
):
from chipcompiler.engine.snapshot import ensure_engineering_snapshot
diff --git a/chipcompiler/engine/workspace_spec.py b/chipcompiler/engine/workspace_spec.py
index 9facf0fd..f95f1c40 100644
--- a/chipcompiler/engine/workspace_spec.py
+++ b/chipcompiler/engine/workspace_spec.py
@@ -2,7 +2,7 @@
from pathlib import Path
from typing import Any
-from chipcompiler.cli.project.params import list_schemas, resolve_parameters
+from chipcompiler.data.parameter_schema import list_schemas, resolve_parameters
from chipcompiler.engine.pdk_binding import pdk_binding_content_hash
from chipcompiler.rtl2gds import get_flow_builders, normalize_flow_step
from chipcompiler.utility.filelist import validate_filelist
From 3b50fc1d2fb052d4b7ef646504878ac0ec35e88a Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 03:45:52 +0800
Subject: [PATCH 12/25] fix(workspace): complete headless lifecycle contracts
Preserve creator input modes, flow filters, read-only configuration and signoff assessment semantics across CLI and headless APIs.
---
chipcompiler/cli/command_handlers/param.py | 16 ++++-
chipcompiler/cli/project/run_prepare.py | 3 +
chipcompiler/engine/signoff/collector.py | 1 +
chipcompiler/engine/signoff_assessment.py | 9 +++
chipcompiler/engine/signoff_export.py | 37 ++++++++++-
chipcompiler/tools/ecc/signoff_checklist.py | 19 +++++-
test/cli/commands/test_workspace_range.py | 1 +
test/cli/params/test_commands.py | 39 -----------
test/cli/params/test_flow_filters.py | 72 +++++++++++++++++++++
test/engine/test_signoff_assessment.py | 41 ++++++++++++
test/engine/test_workspace_spec.py | 21 ++++++
test/test_signoff_package.py | 12 ++++
12 files changed, 225 insertions(+), 46 deletions(-)
create mode 100644 test/cli/params/test_flow_filters.py
create mode 100644 test/engine/test_signoff_assessment.py
diff --git a/chipcompiler/cli/command_handlers/param.py b/chipcompiler/cli/command_handlers/param.py
index 0fe62d6f..0e795c64 100644
--- a/chipcompiler/cli/command_handlers/param.py
+++ b/chipcompiler/cli/command_handlers/param.py
@@ -62,14 +62,28 @@ def param_list(args, ctx: CommandContext) -> CommandResult:
selected_step = normalize_flow_step(getattr(args, "step", None) or "").casefold()
show_all = bool(getattr(args, "all", False))
+ preset = getattr(ctx.config, "flow_preset", "")
+ builder = get_flow_builders().get(preset)
+ flow_step_names = (
+ [normalize_flow_step(step).casefold() for step, _tool, _state in builder()]
+ if builder is not None
+ else []
+ )
+ flow_steps = set(flow_step_names)
+ first_flow_step = flow_step_names[0] if flow_step_names else ""
+ if selected_step and flow_steps and selected_step not in flow_steps:
+ return CommandResult.ok([])
records = []
for rp in resolved:
s = rp.schema
+ applies = normalize_flow_step(s.applies).casefold()
+ if flow_steps and s.applies != "all" and applies not in flow_steps:
+ continue
schema_steps = {
normalize_flow_step(s.group).casefold(),
normalize_flow_step(s.applies).casefold(),
}
- global_at_first_step = s.applies == "all" and selected_step == "synthesis"
+ global_at_first_step = s.applies == "all" and selected_step == first_flow_step
if selected_step and selected_step not in schema_steps and not global_at_first_step:
continue
if not selected_step and not show_all and s.has_direct_target and not rp.is_explicit:
diff --git a/chipcompiler/cli/project/run_prepare.py b/chipcompiler/cli/project/run_prepare.py
index bed0caf2..00fdb580 100644
--- a/chipcompiler/cli/project/run_prepare.py
+++ b/chipcompiler/cli/project/run_prepare.py
@@ -353,6 +353,9 @@ def failed_workspace(reason: str | None) -> CommandResult:
input_filelist = generated_filelist
origin_verilog = ""
parameters = to_parameters(cfg)
+ if uses_netlist_input:
+ # Preserve the creator's input role for Studio/CLI reopen symmetry.
+ parameters["_input_mode"] = "postSynthesis"
pdk_root = resolve_pdk_root(cfg)
if base is not None:
diff --git a/chipcompiler/engine/signoff/collector.py b/chipcompiler/engine/signoff/collector.py
index ba4fac13..a34c7cd6 100644
--- a/chipcompiler/engine/signoff/collector.py
+++ b/chipcompiler/engine/signoff/collector.py
@@ -578,6 +578,7 @@ def add_file(
checklist_data = rebuild_home_checklist(
self.workspace,
resource_issues=[*issues, *analysis_issues],
+ persist=options.materialize,
)
add_file(
"status.checklist",
diff --git a/chipcompiler/engine/signoff_assessment.py b/chipcompiler/engine/signoff_assessment.py
index 9b378ce2..e5018c0a 100644
--- a/chipcompiler/engine/signoff_assessment.py
+++ b/chipcompiler/engine/signoff_assessment.py
@@ -24,6 +24,15 @@ class _ReviewGroup(TypedDict):
def build_signoff_assessment(workspace: Any) -> dict[str, Any]:
+ flow = getattr(workspace, "flow", None)
+ steps_fn = getattr(flow, "steps", None)
+ if callable(steps_fn):
+ steps = steps_fn()
+ else:
+ flow_data = getattr(flow, "data", None)
+ steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else []
+ if steps and any(str(step.get("state", "")) not in {"Success", "Skipped"} for step in steps):
+ return _unavailable_assessment()
checklist = json_read(Path(workspace.directory) / "home" / "checklist.json")
if checklist.get("schema_version") != 3 or checklist.get("kind") != "signoff_checklist":
return _unavailable_assessment()
diff --git a/chipcompiler/engine/signoff_export.py b/chipcompiler/engine/signoff_export.py
index 1c7a19d9..fbeda19b 100644
--- a/chipcompiler/engine/signoff_export.py
+++ b/chipcompiler/engine/signoff_export.py
@@ -44,11 +44,23 @@ def export_signoff_package_archive(
package_dir = Path(result.package_dir)
+ if additional_files is not None and not isinstance(additional_files, list):
+ raise SignoffExportError("additional_files must be a list")
if additional_files:
for file_info in additional_files:
- p = package_dir / file_info["archivePath"]
- p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(file_info["content"], encoding="utf-8")
+ if not isinstance(file_info, dict):
+ raise SignoffExportError("additional file entries must be objects")
+ archive_path = file_info.get("archivePath")
+ content = file_info.get("content")
+ if not isinstance(archive_path, str) or not archive_path:
+ raise SignoffExportError("additional file entries require archivePath")
+ if not isinstance(content, str):
+ raise SignoffExportError("additional file entries require string content")
+ path = _additional_file_path(package_dir, archive_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if _has_symlink_parent(package_dir, path):
+ raise SignoffExportError("additional file path contains a symlink")
+ path.write_text(content, encoding="utf-8")
archive = package_dir.with_suffix(".tar.gz")
import tarfile
@@ -70,3 +82,22 @@ def export_signoff_package_archive(
staged_path.unlink(missing_ok=True)
return str(destination)
+
+
+def _additional_file_path(package_dir: Path, archive_path: str) -> Path:
+ relative = Path(archive_path)
+ if not archive_path or relative.is_absolute() or ".." in relative.parts:
+ raise SignoffExportError("additional file path must stay inside the signoff package")
+ destination = (package_dir / relative).resolve()
+ if not destination.is_relative_to(package_dir.resolve()):
+ raise SignoffExportError("additional file path must stay inside the signoff package")
+ return destination
+
+
+def _has_symlink_parent(package_dir: Path, path: Path) -> bool:
+ current = package_dir
+ for component in path.relative_to(package_dir).parts:
+ current /= component
+ if current.is_symlink():
+ return True
+ return False
diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py
index 25cd0984..304eb23e 100644
--- a/chipcompiler/tools/ecc/signoff_checklist.py
+++ b/chipcompiler/tools/ecc/signoff_checklist.py
@@ -639,7 +639,9 @@ def _package_items(resource_issues) -> list[dict]:
return items
-def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict:
+def rebuild_home_checklist(
+ workspace: Workspace, resource_issues=None, *, persist: bool = True
+) -> dict:
"""Replace the aggregate workspace checklist from current step snapshots."""
workspace_directory = getattr(workspace, "directory", None)
if not workspace_directory:
@@ -692,6 +694,17 @@ def rebuild_home_checklist(workspace: Workspace, resource_issues=None) -> dict:
checklist_path = workspace_dir / "home" / "checklist.json"
if workspace.home.path is not None:
workspace.home.set_checklist(checklist_path)
- checklist = Checklist(checklist_path)
- checklist.replace(list(deduplicated.values()))
+ if persist:
+ checklist = Checklist(checklist_path)
+ checklist.replace(list(deduplicated.values()))
+ else:
+ # Build the same normalized payload without Checklist.__init__ or
+ # replace(), both of which intentionally write to home/checklist.json.
+ checklist = object.__new__(Checklist)
+ checklist.path = Path(checklist_path)
+ checklist.data = checklist._default_data()
+ checklist.data["checklist"] = [
+ checklist._normalize_item(item) for item in deduplicated.values()
+ ]
+ Checklist._refresh_summary(checklist.data)
return checklist.data
diff --git a/test/cli/commands/test_workspace_range.py b/test/cli/commands/test_workspace_range.py
index 3cfce1a9..fe585e3b 100644
--- a/test/cli/commands/test_workspace_range.py
+++ b/test/cli/commands/test_workspace_range.py
@@ -53,6 +53,7 @@ def test_new_workspace_range_uses_ecc_toml_inputs_and_registers_before_execution
assert create_kwargs["directory"] == str(Path(project_dir) / "cts-only")
assert create_kwargs["origin_def"] == str(design_def)
assert create_kwargs["origin_verilog"] == str(netlist)
+ assert create_kwargs["parameters"]["_input_mode"] == "postSynthesis"
assert create_kwargs["flow_config"] == {"start_step": "CTS", "end_step": "CTS"}
manifest = json.loads((Path(project_dir) / "project.json").read_text())
entry = manifest["workspaces"][0]
diff --git a/test/cli/params/test_commands.py b/test/cli/params/test_commands.py
index 015e7836..abeeac71 100644
--- a/test/cli/params/test_commands.py
+++ b/test/cli/params/test_commands.py
@@ -26,20 +26,6 @@ def test_param_list_step_includes_config_schemas(self, tmp_path, capsys, create_
assert rc == 0
assert "cts.skew_bound" in capsys.readouterr().out
- def test_param_list_synthesis_includes_global_parameters(
- self, tmp_path, capsys, create_cli_project, plain_records
- ):
- project_dir = create_cli_project()
- rc = cli_main.run(
- ["param", "list", "--step", "synthesis", "--project", project_dir, "--plain"]
- )
-
- assert rc == 0
- assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [
- "design.frequency_mhz",
- "flow.run_analysis",
- ]
-
class TestParamShow:
def test_param_show_known_key(self, tmp_path, capsys, create_cli_project):
@@ -104,31 +90,6 @@ def test_param_set_rejects_invalid_value(self, tmp_path, capsys, create_cli_proj
rc = cli_main.run(["param", "set", "place.target_density", "1.5", "--project", project_dir])
assert rc == 1
- def test_param_set_rejects_parameter_outside_project_flow(
- self, tmp_path, capsys, create_cli_project, plain_records
- ):
- project_dir = create_cli_project()
- config_path = tmp_path / "gcd" / "ecc.toml"
- config_path.write_text(
- config_path.read_text().replace('preset = "rtl2gds"', 'preset = "syn_sta"')
- )
-
- rc = cli_main.run(
- [
- "param",
- "set",
- "place.target_density",
- "0.65",
- "--project",
- project_dir,
- "--plain",
- ]
- )
-
- assert rc == 1
- assert plain_records(capsys.readouterr().out)[0]["error"] == "parameter_not_in_flow"
- assert "target_density" not in config_path.read_text()
-
def test_param_set_accepts_negative_value(
self, tmp_path, capsys, create_cli_project, plain_records
):
diff --git a/test/cli/params/test_flow_filters.py b/test/cli/params/test_flow_filters.py
new file mode 100644
index 00000000..e9486dc1
--- /dev/null
+++ b/test/cli/params/test_flow_filters.py
@@ -0,0 +1,72 @@
+from chipcompiler.cli import main as cli_main
+
+
+def test_param_list_synthesis_includes_global_parameters(capsys, create_cli_project, plain_records):
+ project_dir = create_cli_project()
+ rc = cli_main.run(["param", "list", "--step", "synthesis", "--project", project_dir, "--plain"])
+
+ assert rc == 0
+ assert [record["param"] for record in plain_records(capsys.readouterr().out)] == [
+ "design.frequency_mhz",
+ "flow.run_analysis",
+ ]
+
+
+def test_param_set_rejects_parameter_outside_project_flow(
+ tmp_path, capsys, create_cli_project, plain_records
+):
+ project_dir = create_cli_project()
+ config_path = tmp_path / "gcd" / "ecc.toml"
+ config_path.write_text(
+ config_path.read_text().replace('preset = "rtl2gds"', 'preset = "syn_sta"')
+ )
+
+ rc = cli_main.run(
+ [
+ "param",
+ "set",
+ "place.target_density",
+ "0.65",
+ "--project",
+ project_dir,
+ "--plain",
+ ]
+ )
+
+ assert rc == 1
+ assert plain_records(capsys.readouterr().out)[0]["error"] == "parameter_not_in_flow"
+ assert "target_density" not in config_path.read_text()
+
+
+def test_param_list_excludes_steps_absent_from_selected_flow(
+ tmp_path, capsys, create_cli_project, plain_records
+):
+ project_dir = create_cli_project()
+ config_path = tmp_path / "gcd" / "ecc.toml"
+ config_path.write_text(
+ config_path.read_text().replace('preset = "rtl2gds"', 'preset = "synthesis_lec"')
+ )
+
+ rc = cli_main.run(["param", "list", "--step", "floorplan", "--project", project_dir, "--plain"])
+
+ assert rc == 0
+ assert plain_records(capsys.readouterr().out) == []
+
+
+def test_param_list_default_excludes_explicit_absent_step_parameters(
+ tmp_path, capsys, create_cli_project, plain_records
+):
+ project_dir = create_cli_project()
+ config_path = tmp_path / "gcd" / "ecc.toml"
+ config_path.write_text(
+ config_path.read_text().replace('preset = "rtl2gds"', 'preset = "synthesis_lec"')
+ + "\n[params.place]\ntarget_density = 0.65\n"
+ )
+
+ rc = cli_main.run(["param", "list", "--project", project_dir, "--plain"])
+
+ assert rc == 0
+ assert all(
+ record["param"] != "place.target_density"
+ for record in plain_records(capsys.readouterr().out)
+ )
diff --git a/test/engine/test_signoff_assessment.py b/test/engine/test_signoff_assessment.py
new file mode 100644
index 00000000..821144b8
--- /dev/null
+++ b/test/engine/test_signoff_assessment.py
@@ -0,0 +1,41 @@
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from chipcompiler.engine.signoff_assessment import build_signoff_assessment
+from chipcompiler.engine.signoff_export import SignoffExportError, _additional_file_path
+
+
+def test_stale_checklist_cannot_make_incomplete_flow_ready(tmp_path):
+ home = tmp_path / "home"
+ home.mkdir()
+ (home / "checklist.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 3,
+ "kind": "signoff_checklist",
+ "status": "ready",
+ "checklist": [],
+ }
+ )
+ )
+ workspace = SimpleNamespace(
+ directory=Path(tmp_path),
+ flow=SimpleNamespace(steps=lambda: [{"name": "Synthesis", "state": "Unstart"}]),
+ )
+
+ result = build_signoff_assessment(workspace)
+
+ assert result["status"] == "blocked"
+
+
+def test_signoff_additional_file_path_rejects_escape(tmp_path):
+ package = tmp_path / "package"
+ package.mkdir()
+
+ for value in ("/tmp/escape", "../escape", ""):
+ try:
+ _additional_file_path(package, value)
+ except SignoffExportError:
+ continue
+ raise AssertionError(f"unsafe path accepted: {value!r}")
diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py
index d67d5a4f..d0703bfd 100644
--- a/test/engine/test_workspace_spec.py
+++ b/test/engine/test_workspace_spec.py
@@ -200,6 +200,27 @@ def test_manual_pdk_workspace_reopens_through_main_persistence(tmp_path):
assert assess_execution_readiness(target, bindings) == {"ready": True}
+def test_partial_flow_spec_preserves_requested_boundaries(tmp_path, minimal_ics55_pdk_factory):
+ from chipcompiler.data import load_workspace
+ from chipcompiler.engine import create_workspace_from_spec
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ spec = deepcopy(payload["workspaceSpec"])
+ spec["flow"] = {
+ "flowId": "rtl2gds",
+ "fromStepId": "Synthesis",
+ "throughStepId": "lec",
+ }
+ spec["parameters"] = {"design.frequency_mhz": 200.0}
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings)
+
+ assert [step["name"] for step in load_workspace(workspace.directory).flow.steps()] == [
+ "Synthesis",
+ "lec",
+ ]
+
+
def test_workspace_spec_update_is_atomic_revisioned_and_idempotent(
tmp_path, minimal_ics55_pdk_factory
):
diff --git a/test/test_signoff_package.py b/test/test_signoff_package.py
index c2c2bfec..1de074f1 100644
--- a/test/test_signoff_package.py
+++ b/test/test_signoff_package.py
@@ -301,6 +301,18 @@ def test_collect_signoff_package_requires_synthesis_verilog(tmp_path):
)
+def test_read_only_signoff_collection_does_not_rewrite_home_checklist(tmp_path):
+ workspace_dir = _make_signoff_workspace(tmp_path)
+ checklist = workspace_dir / "home" / "checklist.json"
+ before = file_digest(checklist)
+
+ _make_engine_flow(workspace_dir).collect_signoff_package(
+ SignoffPackageOptions(archive=False, materialize=False)
+ )
+
+ assert file_digest(checklist) == before
+
+
def test_collect_signoff_package_rejects_stale_post_route_lec_proof(tmp_path):
workspace_dir = _make_signoff_workspace(tmp_path)
gate = workspace_dir / "lvs_ecc" / "output" / "gcd_lvs.v.gz"
From 7e1a0a39a3b9a6628cf5bd1313d2fc23be960243 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 03:46:11 +0800
Subject: [PATCH 13/25] refactor(engine): share canonical QoR scoring
Reuse the report scoring policy for headless assessments and expose the committed step callback contract.
---
chipcompiler/engine/execution.py | 2 +-
chipcompiler/engine/qor_scoring.py | 156 +++++++++--------------------
test/engine/test_execution.py | 27 +++++
3 files changed, 73 insertions(+), 112 deletions(-)
diff --git a/chipcompiler/engine/execution.py b/chipcompiler/engine/execution.py
index e25a2cd0..24faa3dd 100644
--- a/chipcompiler/engine/execution.py
+++ b/chipcompiler/engine/execution.py
@@ -61,7 +61,7 @@ def __init__(self, workspace: Any):
self.workspace = workspace
self.snapshot = ensure_engineering_snapshot(workspace)
- def commit_step(self, _step: Any, state: Any, _error: str | None = None) -> None:
+ def on_step_completed(self, _step: Any, state: Any, _error: str | None = None) -> None:
from chipcompiler.engine.snapshot import commit_engineering_snapshot
self.snapshot = commit_engineering_snapshot(
diff --git a/chipcompiler/engine/qor_scoring.py b/chipcompiler/engine/qor_scoring.py
index 00fc6283..01be13a6 100644
--- a/chipcompiler/engine/qor_scoring.py
+++ b/chipcompiler/engine/qor_scoring.py
@@ -1,58 +1,19 @@
from dataclasses import dataclass
from typing import Any
-QOR_SCORE_THRESHOLD = 60
-
-DIMENSION_WEIGHTS = {
- "timing": 0.35,
- "power_integrity": 0.25,
- "routability_physical": 0.2,
- "area_cost": 0.1,
- "clock_robustness_dfm": 0.1,
- "runtime": 0.0,
-}
-
-METRIC_FAIL_VALUES = {
- "drc_count": 10,
- "lvs_count": 10,
- "route_wirelength": 6000,
- "route_via_count": 2000,
- "cts_buffer_count": 20,
- "cts_buffer_area": 40,
- "clock_wirelength": 400000,
- "cts_clock_wirelength_max": 100000,
- "cts_clock_tree_max_level": 20,
- "die_area": 3000,
- "core_area": 2500,
- "core_utilization": 0.85,
- "synthesis_cell_area": 3000,
- "fanout_max": 100,
- "place_hpwl": 10000,
- "place_grwl": 12000,
- "place_flute_wirelength": 10000,
- "place_congestion_egr_overflow_total": 100,
- "place_congestion_egr_overflow_max": 20,
- "place_rudy_utilization_max": 1,
- "place_lutrudy_utilization_max": 1,
- "route_dr_total_violation_count": 50,
- "route_dr_total_patch_count": 100,
- "route_dr_total_wirelength": 6000,
- "route_dr_total_via_count": 2000,
- "route_la_total_overflow": 100,
- "rcx_missing_corner_count": 9,
- "sta_setup_wns": -0.2,
- "sta_setup_tns": -1,
- "sta_hold_wns": -0.2,
- "sta_hold_tns": -1,
- "sta_frequency_mhz": 100,
- "sta_setup_violation_count": 1,
- "sta_hold_violation_count": 1,
- "sta_missing_corner_count": 1,
- "harden_artifact_missing_count": 6,
-}
-
-_SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"}
-_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3}
+from .qor_report import (
+ _STEP_ENUM_TO_LABEL,
+ QorMetricRecord,
+ _select_project_records,
+ _weighted_overall,
+ score_record,
+)
+from .qor_report import (
+ DIMENSION_WEIGHTS as DIMENSION_WEIGHTS,
+)
+from .qor_report import (
+ QOR_SCORE_THRESHOLD as QOR_SCORE_THRESHOLD,
+)
@dataclass(frozen=True)
@@ -71,7 +32,7 @@ class QorScoringMetric:
@dataclass(frozen=True)
class ScoredQorMetric:
- metric: QorScoringMetric
+ metric: QorMetricRecord
score: float | None
@@ -84,80 +45,53 @@ class QorScoringResult:
def score_qor(records: list[QorScoringMetric]) -> QorScoringResult:
+ canonical = tuple(_canonical_record(record) for record in records)
area_step = next(
(
record.step
- for record in reversed(records)
+ for record in reversed(canonical)
if record.dimension == "area_cost" and record.rating_score
),
None,
)
- selected: dict[tuple[str, str, str], tuple[int, int, QorScoringMetric]] = {}
- for order, record in enumerate(records):
- if record.project_role == "none":
- continue
- if record.dimension == "area_cost" and record.step != area_step:
- continue
- key = (record.metric_id, record.scope, record.corner or "")
- candidate = (_ROLE_PRIORITY.get(record.project_role, 3), -order, record)
- if key not in selected or candidate[:2] < selected[key][:2]:
- selected[key] = candidate
-
+ selected = _select_project_records(canonical, area_step)
scored = tuple(
- ScoredQorMetric(record, score_metric(record) if record.rating_score else None)
- for _role, _order, record in sorted(selected.values(), key=lambda item: item[2].metric_id)
+ ScoredQorMetric(record, score_record(record) if record.rating_score else None)
+ for record in selected
)
by_dimension: dict[str, list[float]] = {}
for item in scored:
if item.score is not None:
by_dimension.setdefault(item.metric.dimension, []).append(item.score)
dimensions = {
- dimension: (
- round(sum(by_dimension[dimension]) / len(by_dimension[dimension]), 1),
- len(by_dimension[dimension]),
- )
- for dimension in DIMENSION_WEIGHTS
- if dimension in by_dimension
+ dimension: (round(sum(values) / len(values), 1), len(values))
+ for dimension, values in by_dimension.items()
}
- weighted = sum(
- score * DIMENSION_WEIGHTS[dimension]
- for dimension, (score, _count) in dimensions.items()
- if DIMENSION_WEIGHTS[dimension] > 0
+ return QorScoringResult(
+ area_step,
+ scored,
+ dimensions,
+ round(_weighted_overall({key: value[0] for key, value in dimensions.items()}), 1)
+ if dimensions
+ else None,
)
- overall = (
- round(weighted, 1)
- if any(DIMENSION_WEIGHTS[dimension] > 0 for dimension in dimensions)
- else None
- )
- return QorScoringResult(area_step, scored, dimensions, overall)
def score_metric(record: QorScoringMetric) -> float | None:
- if record.direction == "trend_only":
- return None
- fail = METRIC_FAIL_VALUES.get(record.metric_id)
- if fail is None:
- return None
- if record.metric_id in _SLACK_METRICS:
- if fail >= 0:
- return None
- return 100.0 if record.value >= 0 else _clamp(100 * (record.value - fail) / -fail)
- if record.direction == "target_range":
- if record.metric_id != "core_utilization":
- return None
- if 0.45 <= record.value <= 0.7:
- return 100.0
- if record.value < 0.45:
- return _clamp(100 * record.value / 0.45)
- return _clamp(100 * (fail - record.value) / (fail - 0.7))
- if fail <= 0:
- return None
- if record.direction == "lower_is_better":
- return _clamp(100 * (fail - record.value) / fail)
- if record.direction == "higher_is_better":
- return _clamp(100 * record.value / fail)
- return None
-
-
-def _clamp(value: float) -> float:
- return max(0.0, min(100.0, value))
+ return score_record(_canonical_record(record))
+
+
+def _canonical_record(record: QorScoringMetric) -> QorMetricRecord:
+ return QorMetricRecord(
+ step=_STEP_ENUM_TO_LABEL.get(record.step, record.step),
+ metric_name=record.metric_id,
+ display_name=record.metric_id,
+ value=record.value,
+ dimension=record.dimension,
+ polarity=record.direction,
+ scope=record.scope,
+ corner=record.corner,
+ project_role=record.project_role,
+ step_role="detail",
+ rating_score=record.rating_score,
+ )
diff --git a/test/engine/test_execution.py b/test/engine/test_execution.py
index ba0851f0..0efef4ef 100644
--- a/test/engine/test_execution.py
+++ b/test/engine/test_execution.py
@@ -48,3 +48,30 @@ def run_steps(self, *, rerun=False, observer=None):
assert not result.succeeded
assert result.state == StateEnum.Imcomplete.value
+
+
+def test_default_execution_observer_commits_completed_steps(monkeypatch, tmp_path):
+ from chipcompiler.engine import execution
+
+ committed = []
+
+ class Flow:
+ workspace = SimpleNamespace(directory=tmp_path)
+
+ def run_steps(self, *, rerun=False, observer=None):
+ observer.on_step_completed(SimpleNamespace(name="synthesis"), StateEnum.Success)
+ return True
+
+ monkeypatch.setattr(
+ execution,
+ "_EngineeringCommitSink",
+ lambda workspace: SimpleNamespace(
+ snapshot={"workspaceId": "workspace-1", "workspaceRevision": 1},
+ on_step_completed=lambda step, state, error=None: committed.append((step.name, state)),
+ ),
+ )
+
+ result = execution.execute(Flow(), execution.ExecutionPlan(intent="run"))
+
+ assert result.succeeded
+ assert committed == [("synthesis", StateEnum.Success)]
From 2259a699c3993889f2bb7078277025cde14946a1 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 03:46:20 +0800
Subject: [PATCH 14/25] chore(deps): remove obsolete RPC server dependencies
Keep ECC headless and leave Runtime Adapter transport dependencies to Studio.
---
flake.nix | 50 ------------
pyproject.toml | 3 -
uv.lock | 211 -------------------------------------------------
3 files changed, 264 deletions(-)
diff --git a/flake.nix b/flake.nix
index 81137fbb..bc01b60c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -42,54 +42,9 @@
pythonImportsCheck = [ "rosettakit" ];
};
- # Not in the pinned nixpkgs; required by chipcompiler's runtime server.
- # Use the wheel: the sdist's bundled versioneer is incompatible with
- # Python 3.13 (configparser.SafeConfigParser was removed).
- oslash = {
- fetchPypi,
- python3Packages,
- }: python3Packages.buildPythonPackage rec {
- pname = "OSlash";
- version = "0.6.3";
- format = "wheel";
-
- src = fetchPypi {
- inherit pname version format;
- dist = "py3";
- python = "py3";
- hash = "sha256-ibl4RDt9s6wmZhBr3DaArdPIhqbY/N0C/QYq+G0pSU8=";
- };
-
- dependencies = [ python3Packages.typing-extensions ];
-
- pythonImportsCheck = [ "oslash" ];
- };
-
- jsonrpcserver = {
- fetchPypi,
- oslash,
- python3Packages,
- }: python3Packages.buildPythonPackage rec {
- pname = "jsonrpcserver";
- version = "5.0.9";
- pyproject = true;
-
- src = fetchPypi {
- inherit pname version;
- hash = "sha256-px+yz6GFQcgJNfYJh/knVdlNdBQSSMdDiEe5bu5cRII=";
- };
-
- build-system = with python3Packages; [ setuptools ];
-
- dependencies = [ python3Packages.jsonschema oslash ];
-
- pythonImportsCheck = [ "jsonrpcserver" ];
- };
-
chipcompiler = {
ecc-dreamplace,
ecc-tools,
- jsonrpcserver,
rosettakit,
yosysWithSlang,
lib,
@@ -114,13 +69,10 @@
dependencies = with python3Packages; [
ecc-dreamplace
ecc-tools
- fastapi
- jsonrpcserver
klayout
matplotlib
numpy
pandas
- pydantic
pyjson5
pyyaml
pyarrow
@@ -130,7 +82,6 @@
tomli-w
tqdm
typer
- uvicorn
pip
];
@@ -157,7 +108,6 @@
packages.default = pkgs.callPackage chipcompiler {
ecc-dreamplace = ecc-dreamplace.packages.${system}.default;
ecc-tools = ecc-tools.packages.${system}.default;
- jsonrpcserver = pkgs.callPackage jsonrpcserver { oslash = pkgs.callPackage oslash {}; };
rosettakit = pkgs.callPackage rosettakit {};
yosysWithSlang = infra.packages.${system}.yosysWithSlang;
};
diff --git a/pyproject.toml b/pyproject.toml
index 48bd95bd..57116b53 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,14 +22,12 @@ classifiers = [
dependencies = [
"ecc-dreamplace==0.1.0a7",
"ecc-tools-bin==0.1.0a13",
- "fastapi>=0.109",
"klayout>=0.30.2",
"matplotlib>=3.4",
"numpy>=1.21",
"pandas>=1.3",
"pyarrow>=15",
"pip>=25.0.1",
- "pydantic>=2.5",
"pyjson5>=1.6",
"pyyaml>=6",
"rosettakit==0.2.0",
@@ -38,7 +36,6 @@ dependencies = [
"torch>=1.6.0",
"tqdm>=4.67.1",
"typer>=0.12",
- "uvicorn>=0.27",
]
scripts.ecc = "chipcompiler.cli.main:main"
diff --git a/uv.lock b/uv.lock
index 353e83f3..80d403d0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -42,28 +42,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.12.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
-]
-
[[package]]
name = "auditwheel"
version = "6.6.0"
@@ -441,14 +419,12 @@ source = { editable = "." }
dependencies = [
{ name = "ecc-dreamplace" },
{ name = "ecc-tools-bin" },
- { name = "fastapi" },
{ name = "klayout" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "pip" },
{ name = "pyarrow" },
- { name = "pydantic" },
{ name = "pyjson5" },
{ name = "pyyaml" },
{ name = "rosettakit" },
@@ -458,7 +434,6 @@ dependencies = [
{ name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" },
{ name = "tqdm" },
{ name = "typer" },
- { name = "uvicorn" },
]
[package.dev-dependencies]
@@ -482,14 +457,12 @@ dev = [
requires-dist = [
{ name = "ecc-dreamplace", editable = "chipcompiler/thirdparty/ecc-dreamplace" },
{ name = "ecc-tools-bin", editable = "chipcompiler/thirdparty/ecc-tools" },
- { name = "fastapi", specifier = ">=0.109" },
{ name = "klayout", specifier = ">=0.30.2" },
{ name = "matplotlib", specifier = ">=3.4" },
{ name = "numpy", specifier = ">=1.21" },
{ name = "pandas", specifier = ">=1.3" },
{ name = "pip", specifier = ">=25.0.1" },
{ name = "pyarrow", specifier = ">=15" },
- { name = "pydantic", specifier = ">=2.5" },
{ name = "pyjson5", specifier = ">=1.6" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "rosettakit", specifier = "==0.2.0" },
@@ -498,7 +471,6 @@ requires-dist = [
{ name = "torch", specifier = ">=1.6.0", index = "https://download.pytorch.org/whl/cpu" },
{ name = "tqdm", specifier = ">=4.67.1" },
{ name = "typer", specifier = ">=0.12" },
- { name = "uvicorn", specifier = ">=0.27" },
]
[package.metadata.requires-dev]
@@ -614,21 +586,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/ee/84c8990b08efa0265bd10fc8781ef26e3157715bf0dfa47ee3c056b513d4/entrypoint2-1.1-py2.py3-none-any.whl", hash = "sha256:eeb8c327bdb65cdd1668c023a6b110b7e3d1a046fb05e043861ebd9264b3a257", size = 9864, upload-time = "2022-06-11T06:28:19.529Z" },
]
-[[package]]
-name = "fastapi"
-version = "0.128.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-doc" },
- { name = "pydantic" },
- { name = "starlette" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
-]
-
[[package]]
name = "filelock"
version = "3.25.2"
@@ -696,24 +653,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
]
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.11"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
-]
-
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1622,118 +1561,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
-[[package]]
-name = "pydantic"
-version = "2.12.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.41.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
- { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
- { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
- { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
- { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
- { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
- { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
- { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
- { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
- { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
- { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
- { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
- { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
- { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
- { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
- { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
- { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
- { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
- { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
- { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
- { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
- { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
- { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
- { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
- { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
- { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
- { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
- { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
- { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
- { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
- { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
- { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
- { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
- { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
- { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
- { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
- { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
- { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
- { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
- { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
- { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
- { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
- { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
- { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
- { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
- { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
- { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
- { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
- { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
- { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
- { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
- { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
- { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
- { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
- { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
- { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
- { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
- { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
- { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
- { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
- { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
- { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
- { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
- { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
- { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
- { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
- { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
- { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
- { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
-]
-
[[package]]
name = "pydoe2"
version = "1.3.0"
@@ -2484,19 +2311,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/81/9ef641ff4e12cbcca30e54e72fb0951a2ba195d0cda0ba4100e532d929db/slicer-0.0.8-py3-none-any.whl", hash = "sha256:6c206258543aecd010d497dc2eca9d2805860a0b3758673903456b7df7934dc3", size = 15251, upload-time = "2024-03-09T07:03:07.708Z" },
]
-[[package]]
-name = "starlette"
-version = "0.50.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
-]
-
[[package]]
name = "statsmodels"
version = "0.14.6"
@@ -2751,18 +2565,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
-[[package]]
-name = "typing-inspection"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
-]
-
[[package]]
name = "tzdata"
version = "2025.3"
@@ -2798,19 +2600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b9/e581dd6a90ba08f0518c70276aa9896eed6efb0d5b480ef85acd7c665722/uv_build-0.10.12-py3-none-win_arm64.whl", hash = "sha256:e4b618934f177f31a930ea18398ae1cb40f35e2d3aca4d09b73afad5cd86b326", size = 1413755, upload-time = "2026-03-19T21:50:51.134Z" },
]
-[[package]]
-name = "uvicorn"
-version = "0.40.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
-]
-
[[package]]
name = "xgboost"
version = "3.2.0"
From 621489ab9142f9ec71a5cc79dadacd46450ea3a8 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 04:09:01 +0800
Subject: [PATCH 15/25] fix(engine): harden query and signoff error boundaries
Keep manifest audit timestamps, tolerate forward-version QoR facts and malformed checklists, and map collector failures to the signoff domain error.
---
chipcompiler/engine/qor_report.py | 10 +++++++--
chipcompiler/engine/qor_scoring.py | 27 ++++++++++++++++++++---
chipcompiler/engine/signoff_assessment.py | 7 +++++-
chipcompiler/engine/signoff_export.py | 19 +++++++++-------
chipcompiler/project/api.py | 9 ++++----
test/engine/test_qor_scoring.py | 11 +++++++++
test/engine/test_signoff_assessment.py | 11 +++++++++
test/project/test_manifest.py | 24 ++++++++++++++++++++
8 files changed, 100 insertions(+), 18 deletions(-)
diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py
index 9e73ef92..2125b7b0 100644
--- a/chipcompiler/engine/qor_report.py
+++ b/chipcompiler/engine/qor_report.py
@@ -310,7 +310,13 @@ def _select_project_records(records, area_scoring_step) -> list[QorMetricRecord]
def _selection_rank(record: QorMetricRecord) -> tuple:
- return (_ROLE_PRIORITY[record.project_role], -FLOW_STEPS.index(record.step))
+ try:
+ step_rank = FLOW_STEPS.index(record.step)
+ except ValueError:
+ # Forward-version/custom steps remain presentable but have no
+ # canonical ordering; prefer known flow evidence when deduplicating.
+ step_rank = -1
+ return (_ROLE_PRIORITY.get(record.project_role, len(_ROLE_PRIORITY)), -step_rank)
def _resolve_area_scoring_step(records, flow_steps_by_label) -> str | None:
@@ -384,7 +390,7 @@ def _weighted_overall(dimension_scores: dict) -> float | None:
weighted_total = 0.0
used_weight = 0.0
for dimension, score in dimension_scores.items():
- weight = DIMENSION_WEIGHTS[dimension]
+ weight = DIMENSION_WEIGHTS.get(dimension, 0.0)
if weight <= 0:
continue
weighted_total += score * weight
diff --git a/chipcompiler/engine/qor_scoring.py b/chipcompiler/engine/qor_scoring.py
index 01be13a6..ce96787e 100644
--- a/chipcompiler/engine/qor_scoring.py
+++ b/chipcompiler/engine/qor_scoring.py
@@ -32,7 +32,7 @@ class QorScoringMetric:
@dataclass(frozen=True)
class ScoredQorMetric:
- metric: QorMetricRecord
+ metric: QorScoringMetric
score: float | None
@@ -45,7 +45,8 @@ class QorScoringResult:
def score_qor(records: list[QorScoringMetric]) -> QorScoringResult:
- canonical = tuple(_canonical_record(record) for record in records)
+ canonical_pairs = tuple((record, _canonical_record(record)) for record in records)
+ canonical = tuple(item[1] for item in canonical_pairs)
area_step = next(
(
record.step
@@ -55,8 +56,14 @@ def score_qor(records: list[QorScoringMetric]) -> QorScoringResult:
None,
)
selected = _select_project_records(canonical, area_step)
+ original_by_canonical_id = {
+ id(canonical_record): original for original, canonical_record in canonical_pairs
+ }
scored = tuple(
- ScoredQorMetric(record, score_record(record) if record.rating_score else None)
+ ScoredQorMetric(
+ original_by_canonical_id.get(id(record), _restore_record(record)),
+ score_record(record) if record.rating_score else None,
+ )
for record in selected
)
by_dimension: dict[str, list[float]] = {}
@@ -95,3 +102,17 @@ def _canonical_record(record: QorScoringMetric) -> QorMetricRecord:
step_role="detail",
rating_score=record.rating_score,
)
+
+
+def _restore_record(record: QorMetricRecord) -> QorScoringMetric:
+ return QorScoringMetric(
+ step=record.step,
+ metric_id=record.metric_name,
+ value=record.value,
+ dimension=record.dimension,
+ direction=record.polarity,
+ scope=record.scope,
+ corner=record.corner,
+ project_role=record.project_role,
+ rating_score=record.rating_score,
+ )
diff --git a/chipcompiler/engine/signoff_assessment.py b/chipcompiler/engine/signoff_assessment.py
index e5018c0a..35c71bf0 100644
--- a/chipcompiler/engine/signoff_assessment.py
+++ b/chipcompiler/engine/signoff_assessment.py
@@ -34,7 +34,12 @@ def build_signoff_assessment(workspace: Any) -> dict[str, Any]:
if steps and any(str(step.get("state", "")) not in {"Success", "Skipped"} for step in steps):
return _unavailable_assessment()
checklist = json_read(Path(workspace.directory) / "home" / "checklist.json")
- if checklist.get("schema_version") != 3 or checklist.get("kind") != "signoff_checklist":
+ if (
+ not isinstance(checklist, dict)
+ or checklist.get("schema_version") != 3
+ or checklist.get("kind") != "signoff_checklist"
+ or not isinstance(checklist.get("checklist"), list)
+ ):
return _unavailable_assessment()
groups: dict[str, _ReviewGroup] = {}
diff --git a/chipcompiler/engine/signoff_export.py b/chipcompiler/engine/signoff_export.py
index fbeda19b..84b23842 100644
--- a/chipcompiler/engine/signoff_export.py
+++ b/chipcompiler/engine/signoff_export.py
@@ -27,15 +27,18 @@ def export_signoff_package_archive(
destination = raw_destination.parent.resolve() / raw_destination.name
with tempfile.TemporaryDirectory(prefix="ecc-signoff-") as temporary_root:
- result = EngineFlow(workspace).collect_signoff_package(
- SignoffPackageOptions(
- output_dir=temporary_root,
- archive=False,
- include_debug=include_debug,
- materialize=False,
- refresh_analysis=False,
+ try:
+ result = EngineFlow(workspace).collect_signoff_package(
+ SignoffPackageOptions(
+ output_dir=temporary_root,
+ archive=False,
+ include_debug=include_debug,
+ materialize=False,
+ refresh_analysis=False,
+ )
)
- )
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ raise SignoffExportError(str(exc)) from exc
if not result.ok:
missing = ", ".join(result.missing_required) or "unknown required resources"
raise SignoffExportError(f"signoff package is incomplete: {missing}")
diff --git a/chipcompiler/project/api.py b/chipcompiler/project/api.py
index 4662f38e..d2ddf918 100644
--- a/chipcompiler/project/api.py
+++ b/chipcompiler/project/api.py
@@ -226,14 +226,15 @@ def _select_workspace(document: dict, mutation: dict) -> None:
raise ManifestError(f"Workspace is not active: {workspace_id}")
field = "qor_baseline" if mutation["type"] == "select_qor_baseline" else "best_workspace"
document[field] = {"workspace_id": workspace_id, "reason": str(mutation.get("reason") or "")}
- document["updated_at"] = str(mutation.get("updated_at") or "")
+ document["updated_at"] = str(mutation.get("updated_at") or datetime.now(UTC).isoformat())
def _archive_workspace(document: dict, mutation: dict) -> None:
workspace = _find_workspace(document, _required_string(mutation, "workspace_id"))
workspace["status"] = "archived"
- workspace["updated_at"] = str(mutation.get("updated_at") or "")
- document["updated_at"] = workspace["updated_at"]
+ timestamp = str(mutation.get("updated_at") or datetime.now(UTC).isoformat())
+ workspace["updated_at"] = timestamp
+ document["updated_at"] = timestamp
def _delete_workspace(document: dict, mutation: dict) -> None:
@@ -251,7 +252,7 @@ def _delete_workspace(document: dict, mutation: dict) -> None:
selection = document.get(field)
if isinstance(selection, dict) and selection.get("workspace_id") == workspace_id:
document[field] = None
- document["updated_at"] = str(mutation.get("updated_at") or "")
+ document["updated_at"] = str(mutation.get("updated_at") or datetime.now(UTC).isoformat())
def _find_workspace(document: dict, workspace_id: str) -> dict:
diff --git a/test/engine/test_qor_scoring.py b/test/engine/test_qor_scoring.py
index d61317db..f186b2f7 100644
--- a/test/engine/test_qor_scoring.py
+++ b/test/engine/test_qor_scoring.py
@@ -32,3 +32,14 @@ def test_qor_scoring_selects_latest_area_and_combines_dimension_weights():
"area_cost": (50.0, 1),
}
assert result.overall_score == 42.5
+
+
+def test_qor_scoring_ignores_forward_version_dimensions_and_steps():
+ result = score_qor(
+ [
+ _metric("future-step", "future_metric", 1, "future_dimension"),
+ _metric("Harden", "die_area", 1500, "area_cost"),
+ ]
+ )
+
+ assert result.dimensions == {"area_cost": (50.0, 1)}
diff --git a/test/engine/test_signoff_assessment.py b/test/engine/test_signoff_assessment.py
index 821144b8..e3ea8fb2 100644
--- a/test/engine/test_signoff_assessment.py
+++ b/test/engine/test_signoff_assessment.py
@@ -29,6 +29,17 @@ def test_stale_checklist_cannot_make_incomplete_flow_ready(tmp_path):
assert result["status"] == "blocked"
+def test_malformed_checklist_is_reported_as_unavailable(tmp_path):
+ home = tmp_path / "home"
+ home.mkdir()
+ (home / "checklist.json").write_text("[]")
+ workspace = SimpleNamespace(directory=Path(tmp_path), flow=None)
+
+ result = build_signoff_assessment(workspace)
+
+ assert result["status"] == "blocked"
+
+
def test_signoff_additional_file_path_rejects_escape(tmp_path):
package = tmp_path / "package"
package.mkdir()
diff --git a/test/project/test_manifest.py b/test/project/test_manifest.py
index c93ed17e..e9f04b01 100644
--- a/test/project/test_manifest.py
+++ b/test/project/test_manifest.py
@@ -51,3 +51,27 @@ def test_register_workspace_uses_main_manifest_shape(tmp_path):
assert updated == load_project_manifest(tmp_path)
assert updated["workspaces"][0]["start_step"] == "Synth"
assert updated["workspaces"][0]["end_step"] == "Synth"
+
+
+def test_manifest_mutation_without_timestamp_keeps_audit_timestamp(tmp_path):
+ workspace = tmp_path / "experiment"
+ (workspace / "home").mkdir(parents=True)
+ (workspace / "home" / "flow.json").write_text(json.dumps({"steps": []}))
+ create_project_manifest(tmp_path, "Demo", "gcd", now="2026-01-01T00:00:00Z")
+ mutate_project_manifest(
+ tmp_path,
+ {
+ "type": "register_workspace",
+ "workspace_id": "experiment",
+ "workspace_path": str(workspace),
+ "name": "Experiment",
+ },
+ )
+
+ updated = mutate_project_manifest(
+ tmp_path,
+ {"type": "archive_workspace", "workspace_id": "experiment"},
+ )
+
+ assert updated["updated_at"]
+ assert updated["workspaces"][0]["updated_at"]
From 69939379255a1537920af88bce8e3a84ffe44eec Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 04:26:01 +0800
Subject: [PATCH 16/25] refactor(project): move manifest ownership to headless
domain
Keep the CLI import paths as module aliases while making Project APIs independent of CLI internals, add read-only workspace loading, and materialize config-target parameters.
---
chipcompiler/cli/project/manifest.py | 424 +----------------
chipcompiler/cli/project/manifest_write.py | 397 +---------------
chipcompiler/data/parameter_schema.py | 4 +-
chipcompiler/data/workspace/__init__.py | 38 +-
.../engine/workspace_configuration.py | 2 +-
chipcompiler/engine/workspace_lifecycle.py | 32 +-
chipcompiler/project/api.py | 4 +-
chipcompiler/project/locking.py | 14 +
chipcompiler/project/manifest.py | 425 ++++++++++++++++++
chipcompiler/project/manifest_write.py | 396 ++++++++++++++++
test/engine/test_workspace_configuration.py | 20 +
test/engine/test_workspace_spec.py | 43 ++
12 files changed, 963 insertions(+), 836 deletions(-)
create mode 100644 chipcompiler/project/locking.py
create mode 100644 chipcompiler/project/manifest.py
create mode 100644 chipcompiler/project/manifest_write.py
diff --git a/chipcompiler/cli/project/manifest.py b/chipcompiler/cli/project/manifest.py
index aaf329c8..57aac98b 100644
--- a/chipcompiler/cli/project/manifest.py
+++ b/chipcompiler/cli/project/manifest.py
@@ -1,423 +1,7 @@
-#!/usr/bin/env python
+"""CLI compatibility alias for the Project Manifest domain module."""
-"""``project.json`` manifest support for the CLI.
+import sys
-The manifest is the GUI's project descriptor (schema v1). The CLI reads it
-for configuration layering and run discovery, and projects it into
-configuration payloads. Write operations (generation, status write-back,
-registration) live in chipcompiler.cli.project.manifest_write, which
-routes every write through one read-modify-write helper.
+from chipcompiler.project import manifest as _domain_manifest
-This module sits on the CLI startup path (imported by
-cli/core/invocation.py): keep module-level imports cheap — no
-chipcompiler.data imports here.
-"""
-
-import json
-import os
-import re
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-
-MANIFEST_FILENAME = "project.json"
-
-# GUI display names for the canonical rtl2gds chain.
-MANIFEST_FLOW_STEPS = (
- "Synth",
- "LEC",
- "Floor",
- "Place",
- "CTS",
- "Legal",
- "TimingOpt",
- "Route",
- "Filler",
- "RCX",
- "STA",
- "LVS",
- "PostRouteLEC",
- "DRC",
- "Harden",
-)
-
-PRESET_MANIFEST_RANGE = {
- "syn_sta": ("Synth", "Synth"),
- "rtl2gds": ("Synth", "Harden"),
- "synthesis_lec": ("Synth", "LEC"),
- # Legacy presets removed from the builder; keep resolving them so
- # persisted projects still load.
- "rcx": ("Synth", "STA"),
- "harden": ("Synth", "Harden"),
-}
-
-_CANONICAL_TO_MANIFEST_STEP = {
- "Synthesis": "Synth",
- "lec": "LEC",
- "Floorplan": "Floor",
- "place": "Place",
- "CTS": "CTS",
- "legalization": "Legal",
- "Timing optimization": "TimingOpt",
- "route": "Route",
- "filler": "Filler",
- "RCX": "RCX",
- "sta": "STA",
- "lvs": "LVS",
- "postRouteLec": "PostRouteLEC",
- "drc": "DRC",
- "Harden": "Harden",
-}
-
-_WORKSPACE_STATUSES = frozenset(
- {"success", "failed", "running", "in_progress", "not_started", "archived"}
-)
-
-
-class ManifestError(ValueError):
- """Raised when a project.json manifest cannot be used (manifest_invalid)."""
-
-
-@dataclass(frozen=True)
-class ManifestWorkspace:
- workspace_id: str
- workspace_path: str
- start_step: str
- end_step: str
- status: str
- parameter_patch: dict = field(default_factory=dict)
- raw: dict = field(default_factory=dict)
-
-
-@dataclass(frozen=True)
-class ProjectManifest:
- project_dir: str
- path: str
- project_id: str
- name: str
- design_name: str
- base_design: dict
- objectives: dict
- workspaces: tuple[ManifestWorkspace, ...]
- qor_baseline: dict | None
- raw: dict
-
- def active_workspaces(self) -> list[ManifestWorkspace]:
- return [w for w in self.workspaces if w.status != "archived"]
-
- def find_workspace(self, workspace_id: str) -> ManifestWorkspace | None:
- """Match a managed workspace by its declared identifier only."""
- for workspace in self.workspaces:
- if workspace.workspace_id == workspace_id:
- return workspace
- return None
-
-
-def _optional_str(value: Any) -> str:
- return value.strip() if isinstance(value, str) else ""
-
-
-def _record(value: Any) -> dict:
- return value if isinstance(value, dict) else {}
-
-
-def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace:
- source = _record(value)
- workspace_id = _optional_str(source.get("workspace_id"))
- workspace_path = _optional_str(source.get("workspace_path"))
- if not workspace_id or not workspace_path:
- raise ManifestError(f"workspaces[{index}] requires workspace_id and workspace_path")
- resolved = Path(workspace_path)
- if not resolved.is_absolute():
- resolved = Path(project_dir) / resolved
- try:
- canonical = resolved.resolve()
- canonical.relative_to(Path(project_dir).resolve())
- except ValueError:
- raise ManifestError(
- f"workspaces[{index}] workspace_path escapes the project root: {workspace_path}"
- ) from None
- except RuntimeError as exc:
- # e.g. a symlink loop inside the spelled path: invalid manifest
- # input, never a traceback.
- raise ManifestError(
- f"workspaces[{index}] workspace_path cannot be resolved: {workspace_path}"
- ) from exc
- status = source.get("status")
- if not isinstance(status, str) or status not in _WORKSPACE_STATUSES:
- status = "not_started"
- start_step = _optional_str(source.get("start_step")) or "Synth"
- end_step = _optional_str(source.get("end_step")) or "Harden"
- for step_name, field_name in ((start_step, "start_step"), (end_step, "end_step")):
- if step_name not in MANIFEST_FLOW_STEPS:
- raise ManifestError(
- f"workspaces[{index}] {field_name} is not on the canonical flow chain: {step_name}"
- )
- if MANIFEST_FLOW_STEPS.index(start_step) > MANIFEST_FLOW_STEPS.index(end_step):
- raise ManifestError(
- f"workspaces[{index}] flow range is reversed: {start_step} -> {end_step}"
- )
- return ManifestWorkspace(
- workspace_id=workspace_id,
- workspace_path=str(canonical),
- start_step=start_step,
- end_step=end_step,
- status=status,
- parameter_patch=_record(source.get("parameter_patch")),
- raw=dict(source),
- )
-
-
-def _validate_mpc(value: Any) -> None:
- """Mirror the GUI parser's mpc rules: null or a well-formed MPC record."""
- if value is None:
- return
- source = _record(value)
- if not source:
- raise ManifestError("invalid project manifest: mpc must be an object or null")
- resource_id = _optional_str(source.get("resource_id"))
- if not resource_id.startswith("mpc:") or len(resource_id) == 4:
- raise ManifestError("invalid project manifest: mpc.resource_id must be an MPC id")
- for field_name in ("display_name", "installed_version", "path", "spec_path"):
- if not _optional_str(source.get(field_name)):
- raise ManifestError(f"invalid project manifest: mpc.{field_name} is required")
- mpc_path = source["path"].rstrip("/")
- if source["spec_path"] != f"{mpc_path}/spec/spec.json.in":
- raise ManifestError(
- "invalid project manifest: mpc.spec_path must reference spec/spec.json.in"
- )
- design = _record(source.get("design"))
- index = design.get("index")
- if (
- not design
- or isinstance(index, bool) # a JSON boolean is not an index (True == 1 in Python)
- or not isinstance(index, (int, float))
- # The GUI accepts integral numbers (0.0). is_integer() applies to
- # floats only: float(huge_int) raises OverflowError, and ints are
- # integral by construction.
- or (isinstance(index, float) and not index.is_integer())
- or index < 0
- or not _optional_str(design.get("design_name"))
- ):
- raise ManifestError(
- "invalid project manifest: mpc.design requires a non-negative index and design_name"
- )
- if not isinstance(source.get("core_template"), dict):
- raise ManifestError("invalid project manifest: mpc.core_template must be an object")
-
-
-def load_manifest(project_dir: str) -> ProjectManifest:
- """Load and tolerantly normalize ``/project.json``.
-
- Mirrors the GUI parser's contract: schema_version 1 and a workspaces
- array are required, everything else is default-filled. Raises
- ManifestError on parse failure, root_path mismatch, or a workspace
- path outside the project root.
- """
- path = os.path.join(project_dir, MANIFEST_FILENAME)
- try:
- with open(path, encoding="utf-8") as f:
- source = json.load(f)
- except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
- raise ManifestError(f"invalid project manifest: {path}: {exc}") from exc
-
- if not isinstance(source, dict):
- raise ManifestError(f"invalid project manifest: {path}: top level must be an object")
- schema_version = source.get("schema_version")
- # A JSON boolean is not the schema version (True == 1 in Python); the
- # GUI parser rejects it, and the CLI must not open what the GUI cannot.
- if isinstance(schema_version, bool) or schema_version != 1:
- raise ManifestError("invalid project manifest: schema_version 1 is required")
- raw_workspaces = source.get("workspaces")
- if not isinstance(raw_workspaces, list):
- raise ManifestError("invalid project manifest: workspaces must be an array")
-
- root_path = _optional_str(source.get("root_path"))
- if not root_path:
- raise ManifestError("invalid project manifest: root_path is required")
- if os.path.realpath(root_path) != os.path.realpath(project_dir):
- raise ManifestError(
- f"invalid project manifest: root_path {root_path} does not match {project_dir}"
- )
- design_name = _optional_str(source.get("design_name"))
- if not design_name:
- raise ManifestError("invalid project manifest: design_name is required")
-
- name = _optional_str(source.get("name")) or os.path.basename(project_dir) or "project"
- base_design = _record(source.get("base_design"))
- base_design = {**base_design, "parameters": _record(base_design.get("parameters"))}
- # Mirror the GUI parser: primary defaults to "timing", directions keep
- # only maximize/minimize entries from the source (no default fill).
- objectives_raw = _record(source.get("objectives"))
- objectives = dict(objectives_raw)
- objectives["primary"] = _optional_str(objectives_raw.get("primary")) or "timing"
- objectives["directions"] = {
- key: value
- for key, value in _record(objectives_raw.get("directions")).items()
- if value in ("maximize", "minimize")
- }
- qor_baseline_raw = _record(source.get("qor_baseline"))
- qor_baseline = None
- if _optional_str(qor_baseline_raw.get("workspace_id")):
- qor_baseline = {
- "workspace_id": qor_baseline_raw["workspace_id"],
- "reason": _optional_str(qor_baseline_raw.get("reason")) or "Project QoR baseline",
- }
-
- _validate_mpc(source.get("mpc"))
-
- return ProjectManifest(
- project_dir=project_dir,
- path=path,
- project_id=_optional_str(source.get("project_id")) or f"proj_{_slugify(name)}",
- name=name,
- design_name=design_name,
- base_design=base_design,
- objectives=objectives,
- workspaces=tuple(
- _normalize_workspace_entry(entry, index, project_dir)
- for index, entry in enumerate(raw_workspaces)
- ),
- qor_baseline=qor_baseline,
- raw=source,
- )
-
-
-def find_manifest(project_dir: str) -> str | None:
- """The manifest path when the entry lexically exists.
-
- Lexical presence, not readability: a directory, symlink loop, or
- dangling symlink still counts as PRESENT so ``load_manifest`` fails
- loud (manifest_invalid) instead of silently demoting the project to
- the virgin or legacy layout.
- """
- path = os.path.join(project_dir, MANIFEST_FILENAME)
- return path if os.path.lexists(path) else None
-
-
-def has_legacy_runs_layout(project_dir: str) -> bool:
- runs_dir = os.path.join(project_dir, "runs")
- if not os.path.isdir(runs_dir):
- return False
- try:
- return any(os.path.isdir(os.path.join(runs_dir, entry)) for entry in os.listdir(runs_dir))
- except OSError:
- return False
-
-
-def classify_project(project_dir: str) -> str:
- """Classify a project directory: manifest | legacy | virgin."""
- if find_manifest(project_dir) is not None:
- return "manifest"
- if has_legacy_runs_layout(project_dir):
- return "legacy"
- return "virgin"
-
-
-def assemble_config(manifest: ProjectManifest, workspace: ManifestWorkspace | None) -> dict:
- """Flatten the manifest into a parameter payload (lowest precedence layer).
-
- ``base_design.parameters`` plus the workspace's ``parameter_patch`` form
- the base layer beneath project ecc.toml and --set overrides.
- """
- parameters = dict(manifest.base_design.get("parameters") or {})
- if workspace is not None:
- for key, change in workspace.parameter_patch.items():
- parameters[key] = (
- change["to"] if isinstance(change, dict) and "to" in change else change
- )
- if manifest.design_name and not _optional_str(parameters.get("design")):
- parameters["design"] = manifest.design_name
- rtl_list = manifest.base_design.get("rtl_list")
- if not isinstance(rtl_list, list):
- rtl_list = []
- return {
- "pdk": _optional_str(manifest.base_design.get("pdk")),
- "pdk_root": _optional_str(manifest.base_design.get("pdk_root")),
- "design_name": manifest.design_name,
- "top_module": _optional_str(manifest.base_design.get("top_module")),
- "clock": _optional_str(manifest.base_design.get("clock")),
- "rtl_list": [item for item in rtl_list if isinstance(item, str)],
- "origin_verilog": _optional_str(manifest.base_design.get("origin_verilog")),
- "origin_def": _optional_str(manifest.base_design.get("origin_def")),
- "netlist": _optional_str(manifest.base_design.get("netlist")),
- "golden_netlist": _optional_str(manifest.base_design.get("golden_netlist")),
- "sdc": _optional_str(manifest.base_design.get("sdc")),
- "spef": _optional_str(manifest.base_design.get("spef")),
- "parameters": parameters,
- }
-
-
-def resolved_base_parameters(cfg) -> dict:
- """The ecc.toml-resolved base_design.parameters for a generated manifest.
-
- GUI-flat vocabulary: identity fields plus the [params] overrides,
- projected through the geometry converter so positional values surface
- as the wizard's aliases (die_width, utilitization, margin, ...).
- --set values are run-scoped and never included.
- """
- canonical: dict = {
- "design": cfg.design_name,
- "top_module": cfg.design_top,
- "clock": cfg.design_clock_port,
- "frequency_max": cfg.design_frequency_mhz,
- }
- if cfg.params_overrides:
- from chipcompiler.cli.project.params import (
- build_backend_overrides,
- resolve_parameters,
- )
-
- resolved, _ = resolve_parameters(toml_overrides=cfg.params_overrides)
- canonical.update(build_backend_overrides(resolved))
-
- from chipcompiler.data.parameter_keys import parameters_to_geometry
-
- flat = parameters_to_geometry(canonical)
- # Exclusive GUI-flat shape: geometry lives only in the aliases —
- # the canonical die/core subtrees are consumed, not duplicated.
- # Non-positional members (e.g. aspect_ratio) hoist to flat top-level
- # keys; positional members are covered by the aliases.
- for subtree_name in ("die", "core"):
- subtree = flat.pop(subtree_name, None)
- if not isinstance(subtree, dict):
- continue
- for member, value in subtree.items():
- if member in ("size", "utilitization", "margin"):
- continue
- flat.setdefault(member, value)
- return flat
-
-
-def base_design_from_config(cfg, pdk_root: str) -> dict:
- """The base_design document for a generated manifest.
-
- Identity and sources come from the ecc.toml-resolved config with the
- DECLARED project source spellings preserved: ``rtl_list`` verbatim,
- and ``origin_verilog`` when the single source is plain RTL (empty for
- a filelist source; the document builder drops empty keys). Parameters
- are the GUI-flat projection. Shared by virgin generation and first
- migration so the two writers cannot drift.
- """
- from chipcompiler.cli.project.config import resolve_rtl
-
- _, origin_verilog, _ = resolve_rtl(cfg)
- return {
- "pdk": cfg.pdk_name,
- "pdk_root": pdk_root,
- "top_module": cfg.design_top,
- "clock": cfg.design_clock_port,
- "rtl_list": cfg.design_rtl,
- "origin_verilog": cfg.design_rtl[0] if origin_verilog else "",
- "origin_def": cfg.design_def,
- "netlist": cfg.design_netlist,
- "golden_netlist": cfg.design_golden_netlist,
- "sdc": cfg.design_sdc,
- "spef": cfg.design_spef,
- "parameters": resolved_base_parameters(cfg),
- }
-
-
-def _slugify(value: str) -> str:
- slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
- return slug or "project"
+sys.modules[__name__] = _domain_manifest
diff --git a/chipcompiler/cli/project/manifest_write.py b/chipcompiler/cli/project/manifest_write.py
index bc90e154..61eb0ca3 100644
--- a/chipcompiler/cli/project/manifest_write.py
+++ b/chipcompiler/cli/project/manifest_write.py
@@ -1,396 +1,7 @@
-#!/usr/bin/env python
+"""CLI compatibility alias for Project Manifest mutations."""
-"""Manifest write, mutation, and registration operations for the CLI.
+import sys
-All ``project.json`` writes go through one read-modify-write helper so
-status write-back and migration entry-append share the same atomicity
-story. Loading and normalization live in
-chipcompiler.cli.project.manifest; this module imports from it, never
-the reverse.
+from chipcompiler.project import manifest_write as _domain_manifest_write
-Like manifest.py, this module sits on the CLI startup path (imported by
-run dispatch and migration flows): keep module-level imports cheap — no
-chipcompiler.data imports here.
-"""
-
-import json
-import logging
-import os
-import tempfile
-from copy import deepcopy
-from datetime import UTC, datetime
-from pathlib import Path
-from typing import Any
-
-from chipcompiler.cli.project.manifest import (
- _CANONICAL_TO_MANIFEST_STEP,
- MANIFEST_FILENAME,
- PRESET_MANIFEST_RANGE,
- ManifestError,
- _record,
- _slugify,
- base_design_from_config,
-)
-
-logger = logging.getLogger(__name__)
-
-DEFAULT_OBJECTIVES = {
- "primary": "timing",
- "directions": {
- "wns": "maximize",
- "tns": "maximize",
- "area": "minimize",
- "drc_count": "minimize",
- "lvs_count": "minimize",
- "power": "minimize",
- },
-}
-
-
-def _now_iso() -> str:
- return datetime.now(UTC).isoformat()
-
-
-def manifest_workspace_entry(
- workspace_id: str,
- *,
- name: str,
- workspace_path: str,
- start_step: str,
- end_step: str,
- status: str,
- now: str,
-) -> dict:
- """One complete schema-v1 workspaces[] entry, every field materialized.
-
- The single builder for generated manifests and migration previews, so
- the previewed entry and the applied entry are the same object shape.
- """
- return {
- "workspace_id": workspace_id,
- "name": name,
- "workspace_path": workspace_path,
- "source_workspace_id": None,
- "branch_from": None,
- "start_step": start_step,
- "end_step": end_step,
- "status": status,
- "created_at": now,
- "updated_at": now,
- "parameter_patch": {},
- "metrics_summary": {},
- "step_metrics": {},
- }
-
-
-def build_manifest_document(
- project_dir: str,
- *,
- design_name: str,
- base_design: dict,
- workspace_id: str,
- workspace_path: str,
- start_step: str,
- end_step: str,
- status: str = "running",
-) -> dict:
- """Assemble a schema-v1 manifest for a virgin project's first run."""
- now = _now_iso()
- document = build_project_document(
- project_dir,
- design_name=design_name,
- base_design=base_design,
- now=now,
- )
- document["workspaces"] = [
- manifest_workspace_entry(
- workspace_id,
- name=design_name,
- workspace_path=workspace_path,
- start_step=start_step,
- end_step=end_step,
- status=status,
- now=now,
- )
- ]
- document["qor_baseline"] = {
- "workspace_id": workspace_id,
- "reason": "Default project QoR baseline",
- }
- return document
-
-
-def build_project_document(
- project_dir: str,
- *,
- design_name: str,
- base_design: dict,
- name: str | None = None,
- now: str | None = None,
- mpc: dict | None = None,
-) -> dict[str, Any]:
- """Assemble a schema-v1 Project Manifest without a Workspace."""
- timestamp = now or _now_iso()
- project_name = name or os.path.basename(os.path.normpath(project_dir)) or "project"
- return {
- "schema_version": 1,
- "project_id": f"proj_{_slugify(project_name)}",
- "name": project_name,
- "design_name": design_name,
- "description": "",
- "root_path": project_dir,
- "created_at": timestamp,
- "updated_at": timestamp,
- "base_design": {
- **{key: value for key, value in base_design.items() if key != "parameters" and value},
- "parameters": _record(base_design.get("parameters")),
- "rtl_list": [
- item for item in base_design.get("rtl_list") or [] if isinstance(item, str)
- ],
- },
- "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)),
- "workspaces": [],
- "mpc": deepcopy(mpc),
- "best_workspace": None,
- "qor_baseline": None,
- }
-
-
-def write_manifest_if_absent(project_dir: str, document: dict) -> bool:
- """Write the manifest only when it does not exist (virgin generation race).
-
- Fully written and fsynced at a temp path, then linked into place:
- readers never see a partial file, and a concurrent creator wins the
- link — ours is discarded and the caller continues read-only.
- """
- path = os.path.join(project_dir, MANIFEST_FILENAME)
- content = json.dumps(document, indent=2) + "\n"
- tmp_path = None
- try:
- with tempfile.NamedTemporaryFile(
- "w",
- dir=project_dir,
- delete=False,
- prefix=f".{MANIFEST_FILENAME}.",
- suffix=".tmp",
- encoding="utf-8",
- ) as f:
- tmp_path = f.name
- f.write(content)
- f.flush()
- os.fsync(f.fileno())
- # Mode stays the tempfile default (0600), matching json_write's
- # convention for newly created state files.
- os.link(tmp_path, path)
- return True
- except FileExistsError:
- return False
- except OSError as exc:
- logger.warning("manifest write failed: %s: %s", path, exc)
- return False
- finally:
- if tmp_path is not None:
- Path(tmp_path).unlink(missing_ok=True)
-
-
-def _read_manifest_document(path: str):
- try:
- with open(path, encoding="utf-8") as f:
- document = json.load(f)
- except (OSError, json.JSONDecodeError, UnicodeDecodeError):
- return None
- return document if isinstance(document, dict) else None
-
-
-def update_manifest(project_dir: str, mutator) -> bool:
- """Read-modify-write the manifest atomically (locked re-read + patch + replace).
-
- The whole read-modify-replace runs under ``.manifest.lock`` (flock):
- two cooperating writers can no longer both complete the fresh read
- before either replaces, so neither loses the other's update. The
- mutator receives the parsed document and edits it in place. When an
- unrelated change lands between the read and the write, the mutator is
- re-applied to the freshest document instead of overwriting the change.
- Project-level fields (including updated_at) are owned by the mutator.
- Returns False (with a warning) when the manifest is missing, unreadable,
- the lock cannot be taken, or the write fails — callers degrade to a
- warning, never a run failure.
- """
- from chipcompiler.cli.project.migrate_fs import flock_file
-
- path = os.path.join(project_dir, MANIFEST_FILENAME)
- try:
- with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True):
- return _update_manifest_locked(path, mutator)
- except OSError as exc:
- # An untakeable lock (e.g. a directory at the lock path) degrades
- # like any write failure: a warning, never an uncaught exception —
- # the migration registration path relies on False to roll back.
- logger.warning("manifest update failed: %s: %s", path, exc)
- return False
-
-
-def _update_manifest_locked(path: str, mutator) -> bool:
- base = _read_manifest_document(path)
- if base is None:
- logger.warning("manifest update skipped (unreadable): %s", path)
- return False
-
- document = deepcopy(base)
- mutator(document)
-
- fresh = _read_manifest_document(path)
- if fresh is not None and fresh != base:
- # An unrelated edit landed after our read: re-apply the mutator to
- # the freshest document so the interleaved change survives.
- document = fresh
- mutator(document)
-
- target = Path(path)
- tmp_path = None
- try:
- with tempfile.NamedTemporaryFile(
- "w",
- dir=target.parent,
- delete=False,
- prefix=f".{target.name}.",
- suffix=".tmp",
- encoding="utf-8",
- ) as f:
- tmp_path = Path(f.name)
- json.dump(document, f, indent=2)
- f.write("\n")
- f.flush()
- os.fsync(f.fileno())
- # Preserve the existing manifest's permissions: mkstemp's 0600 must
- # not silently narrow a shared project.json.
- if target.exists():
- os.chmod(tmp_path, target.stat().st_mode & 0o7777)
- os.replace(tmp_path, target)
- return True
- except OSError as exc:
- logger.warning("manifest update failed: %s: %s", path, exc)
- if tmp_path is not None:
- tmp_path.unlink(missing_ok=True)
- return False
-
-
-def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool:
- """Update one workspace entry's status (and updated_at) after a run."""
-
- def mutate(document: dict) -> None:
- for entry in document.get("workspaces", []):
- if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id:
- entry["status"] = status
- entry["updated_at"] = _now_iso()
-
- return update_manifest(project_dir, mutate)
-
-
-def remove_workspace_registration(project_dir: str, workspace_id: str) -> bool:
- """Roll back a pre-registration: drop the freshly added entry.
-
- Used when an overwrite run against an undeclared workspace fails before
- the replacement is constructed: the restored previous workspace must not
- be shadowed by a stale ``not_started`` entry this invocation created.
- """
-
- def mutate(document: dict) -> None:
- workspaces = document.get("workspaces")
- if isinstance(workspaces, list):
- document["workspaces"] = [
- entry
- for entry in workspaces
- if not (isinstance(entry, dict) and entry.get("workspace_id") == workspace_id)
- ]
- document["updated_at"] = _now_iso()
-
- return update_manifest(project_dir, mutate)
-
-
-def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]:
- """Return the GUI manifest range for a workspace's effective target."""
- if isinstance(flow_config, dict) and flow_config.get("start_step"):
- from chipcompiler.rtl2gds import normalize_flow_step
-
- start = normalize_flow_step(flow_config["start_step"])
- end = normalize_flow_step(flow_config.get("end_step") or start)
- try:
- return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end])
- except KeyError as exc:
- raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None
- return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden"))
-
-
-def pre_register_workspace(
- project_dir: str,
- *,
- cfg,
- pdk_root: str,
- workspace_id: str,
- workspace_path: str,
- flow_config: dict | None,
-) -> str:
- """Atomically register a fresh managed workspace before filesystem creation.
-
- Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A
- workspace entry intentionally contains no input snapshot: copied files and
- the workspace config are the reproducibility boundary.
- """
- try:
- start_step, end_step = manifest_range_for_flow(cfg, flow_config)
- except ManifestError:
- return "failed"
- now = _now_iso()
- manifest_path = os.path.join(project_dir, MANIFEST_FILENAME)
- if not os.path.lexists(manifest_path):
- document = build_manifest_document(
- project_dir,
- design_name=cfg.design_name,
- base_design=base_design_from_config(cfg, pdk_root),
- workspace_id=workspace_id,
- workspace_path=workspace_path,
- start_step=start_step,
- end_step=end_step,
- status="not_started",
- )
- if write_manifest_if_absent(project_dir, document):
- return "registered"
- # A concurrent creator won the link race: fall through and apply the
- # same registration mutation under the manifest lock instead of
- # aborting this run. A genuine I/O failure fails again below.
-
- outcome = "registered"
-
- def mutate(document: dict) -> None:
- nonlocal outcome
- workspaces = document.get("workspaces")
- if not isinstance(workspaces, list):
- outcome = "failed"
- return
- for entry in workspaces:
- if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id:
- continue
- if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath(
- workspace_path
- ):
- outcome = "existing"
- else:
- outcome = "conflict"
- return
- workspaces.append(
- manifest_workspace_entry(
- workspace_id,
- name=cfg.design_name,
- workspace_path=workspace_path,
- start_step=start_step,
- end_step=end_step,
- status="not_started",
- now=now,
- )
- )
- document["updated_at"] = now
-
- if not update_manifest(project_dir, mutate):
- return "failed"
- return outcome
+sys.modules[__name__] = _domain_manifest_write
diff --git a/chipcompiler/data/parameter_schema.py b/chipcompiler/data/parameter_schema.py
index 45bd1a53..3dc10876 100644
--- a/chipcompiler/data/parameter_schema.py
+++ b/chipcompiler/data/parameter_schema.py
@@ -396,10 +396,10 @@ def coerce_manifest_parameters(canonical: dict, registry=PARAM_REGISTRY, skip_pa
return coerced, errors
-def build_backend_overrides(resolved):
+def build_backend_overrides(resolved, *, include_defaults: bool = False):
overrides = {}
for item in resolved:
- if item.source == "default" and item.value == item.default:
+ if not include_defaults and item.source == "default" and item.value == item.default:
continue
target, value = item.schema.maps_to, item.value
if isinstance(target, str):
diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py
index d75f5d3d..a3013dc5 100644
--- a/chipcompiler/data/workspace/__init__.py
+++ b/chipcompiler/data/workspace/__init__.py
@@ -1189,19 +1189,21 @@ def _persisted_golden_verilog(workspace_dir: Path) -> tuple[Path | None, bool]:
return None, True
-def load_workspace(directory: str | Path) -> Workspace:
+def load_workspace(directory: str | Path, *, read_only: bool = False) -> Workspace:
workspace_dir = Path(directory).expanduser().resolve()
origin_dir = workspace_dir / "origin"
home_dir = workspace_dir / "home"
if not workspace_dir.exists():
return None
- migrate_legacy_parameters(workspace_dir)
+ if not read_only:
+ migrate_legacy_parameters(workspace_dir)
# create workspace instance
workspace = Workspace()
workspace.directory = workspace_dir
- migrate_workspace_config_filenames(workspace_dir)
+ if not read_only:
+ migrate_workspace_config_filenames(workspace_dir)
workspace.config = build_workspace_config_paths(workspace)
config_path = workspace_config_toml_path(workspace_dir)
@@ -1287,19 +1289,27 @@ def load_workspace(directory: str | Path) -> Workspace:
workspace.design.input_filelist = filelist_path
# set home data
- home_dir.mkdir(parents=True, exist_ok=True)
- workspace.config["dir"].mkdir(parents=True, exist_ok=True)
workspace.flow.path = home_dir / "flow.json"
- workspace.home.init(path=home_dir / "home.json")
- workspace.home.set_flow(workspace.flow.path)
- workspace.home.set_checklist(home_dir / "checklist.json")
- workspace.home.set_parameters(workspace.parameters.path)
-
- # create logger first (needed for copy operations)
- workspace.logger = create_logger(name=parameters.data["design"], log_dir=workspace_dir / "log")
+ if read_only:
+ workspace.home.path = home_dir / "home.json"
+ home_data = json_read(workspace.home.path)
+ workspace.home.data = home_data if isinstance(home_data, dict) else {}
+ workspace.logger = Logger(name=parameters.data["design"])
+ else:
+ home_dir.mkdir(parents=True, exist_ok=True)
+ workspace.config["dir"].mkdir(parents=True, exist_ok=True)
+ workspace.home.init(path=home_dir / "home.json")
+ workspace.home.set_flow(workspace.flow.path)
+ workspace.home.set_checklist(home_dir / "checklist.json")
+ workspace.home.set_parameters(workspace.parameters.path)
+
+ # create logger first (needed for copy operations)
+ workspace.logger = create_logger(
+ name=parameters.data["design"], log_dir=workspace_dir / "log"
+ )
- log_workspace(workspace)
- log_parameters(workspace)
+ log_workspace(workspace)
+ log_parameters(workspace)
return workspace
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
index 10d3de6d..7964408b 100644
--- a/chipcompiler/engine/workspace_configuration.py
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -389,7 +389,7 @@ def read_workspace_configuration_from_directory(
target_directory: str | Path,
) -> dict[str, Any]:
target = Path(target_directory).expanduser().resolve()
- workspace = load_workspace(target)
+ workspace = load_workspace(target, read_only=True)
if workspace is None:
raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
return read_workspace_configuration(workspace)
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index e4db911c..b8c5e073 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -9,7 +9,12 @@
from typing import Any
from chipcompiler.data import PDK, create_workspace, get_pdk, load_workspace
-from chipcompiler.data.parameter_schema import build_backend_overrides, resolve_parameters
+from chipcompiler.data.parameter_schema import (
+ build_backend_overrides,
+ build_config_overrides,
+ resolve_parameters,
+)
+from chipcompiler.data.workspace.config_overrides import CONFIG_OVERRIDES_KEY
from chipcompiler.engine.snapshot import create_engineering_snapshot
from chipcompiler.engine.workspace_spec import validate_workspace_spec
from chipcompiler.rtl2gds import get_flow_builders
@@ -127,7 +132,10 @@ def _create_workspace_from_spec(
"Workspace parameters are invalid",
{"issues": errors},
)
- backend_parameters = build_backend_overrides(parameters)
+ backend_parameters = build_backend_overrides(parameters, include_defaults=True)
+ config_overrides = build_config_overrides(parameters)
+ if config_overrides:
+ backend_parameters[CONFIG_OVERRIDES_KEY] = config_overrides
backend_parameters.update(
{
"design": resolved["design"]["name"],
@@ -232,7 +240,11 @@ def _update_workspace_from_spec(
bindings: object,
command_id: str = "",
):
- from chipcompiler.engine.snapshot import ensure_engineering_snapshot
+ from chipcompiler.engine.snapshot import (
+ EngineeringSnapshotError,
+ ensure_engineering_snapshot,
+ read_engineering_snapshot,
+ )
target = Path(target_directory).expanduser().resolve()
fingerprint = _workspace_command_fingerprint(
@@ -241,7 +253,19 @@ def _update_workspace_from_spec(
if command_id and _command_retry_matches(target, command_id, fingerprint):
return _load_committed_workspace(target)
current = _load_committed_workspace(target)
- snapshot = ensure_engineering_snapshot(current)
+ try:
+ snapshot = read_engineering_snapshot(current)
+ except EngineeringSnapshotError as exc:
+ if expected_workspace_revision != 1:
+ raise WorkspaceLifecycleError(
+ "revision_conflict",
+ "Workspace Revision does not match",
+ {
+ "expectedWorkspaceRevision": expected_workspace_revision,
+ "actualWorkspaceRevision": 1,
+ },
+ ) from exc
+ snapshot = ensure_engineering_snapshot(current)
if snapshot["workspaceRevision"] != expected_workspace_revision:
raise WorkspaceLifecycleError(
"revision_conflict",
diff --git a/chipcompiler/project/api.py b/chipcompiler/project/api.py
index d2ddf918..d40abba2 100644
--- a/chipcompiler/project/api.py
+++ b/chipcompiler/project/api.py
@@ -4,12 +4,12 @@
from datetime import UTC, datetime
from pathlib import Path
-from chipcompiler.cli.project.manifest import (
+from chipcompiler.project.manifest import (
_CANONICAL_TO_MANIFEST_STEP,
ManifestError,
load_manifest,
)
-from chipcompiler.cli.project.manifest_write import (
+from chipcompiler.project.manifest_write import (
build_project_document,
manifest_workspace_entry,
update_manifest,
diff --git a/chipcompiler/project/locking.py b/chipcompiler/project/locking.py
new file mode 100644
index 00000000..6a8f4082
--- /dev/null
+++ b/chipcompiler/project/locking.py
@@ -0,0 +1,14 @@
+"""Project-domain file lock helper."""
+
+import fcntl
+from contextlib import contextmanager
+
+
+@contextmanager
+def flock_file(path: str, *, exclusive: bool = True):
+ with open(path, "a") as lock_file:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
+ try:
+ yield lock_file
+ finally:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
diff --git a/chipcompiler/project/manifest.py b/chipcompiler/project/manifest.py
new file mode 100644
index 00000000..99d51ba0
--- /dev/null
+++ b/chipcompiler/project/manifest.py
@@ -0,0 +1,425 @@
+#!/usr/bin/env python
+
+"""``project.json`` manifest support for the CLI.
+
+The manifest is the GUI's project descriptor (schema v1). The CLI reads it
+for configuration layering and run discovery, and projects it into
+configuration payloads. Write operations (generation, status write-back,
+registration) live in chipcompiler.project.manifest_write, which
+routes every write through one read-modify-write helper.
+
+This module sits on the CLI startup path (imported by
+cli/core/invocation.py): keep module-level imports cheap — no
+chipcompiler.data imports here.
+"""
+
+import json
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+MANIFEST_FILENAME = "project.json"
+
+# GUI display names for the canonical rtl2gds chain.
+MANIFEST_FLOW_STEPS = (
+ "Synth",
+ "LEC",
+ "Floor",
+ "Place",
+ "CTS",
+ "Legal",
+ "TimingOpt",
+ "Route",
+ "Filler",
+ "RCX",
+ "STA",
+ "LVS",
+ "PostRouteLEC",
+ "DRC",
+ "Harden",
+)
+
+PRESET_MANIFEST_RANGE = {
+ "syn_sta": ("Synth", "Synth"),
+ "rtl2gds": ("Synth", "Harden"),
+ "synthesis_lec": ("Synth", "LEC"),
+ # Legacy presets removed from the builder; keep resolving them so
+ # persisted projects still load.
+ "rcx": ("Synth", "STA"),
+ "harden": ("Synth", "Harden"),
+}
+
+_CANONICAL_TO_MANIFEST_STEP = {
+ "Synthesis": "Synth",
+ "lec": "LEC",
+ "Floorplan": "Floor",
+ "place": "Place",
+ "CTS": "CTS",
+ "legalization": "Legal",
+ "Timing optimization": "TimingOpt",
+ "route": "Route",
+ "filler": "Filler",
+ "RCX": "RCX",
+ "sta": "STA",
+ "lvs": "LVS",
+ "postRouteLec": "PostRouteLEC",
+ "drc": "DRC",
+ "Harden": "Harden",
+}
+
+_WORKSPACE_STATUSES = frozenset(
+ {"success", "failed", "running", "in_progress", "not_started", "archived"}
+)
+
+
+class ManifestError(ValueError):
+ """Raised when a project.json manifest cannot be used (manifest_invalid)."""
+
+
+@dataclass(frozen=True)
+class ManifestWorkspace:
+ workspace_id: str
+ workspace_path: str
+ start_step: str
+ end_step: str
+ status: str
+ parameter_patch: dict = field(default_factory=dict)
+ raw: dict = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class ProjectManifest:
+ project_dir: str
+ path: str
+ project_id: str
+ name: str
+ design_name: str
+ base_design: dict
+ objectives: dict
+ workspaces: tuple[ManifestWorkspace, ...]
+ qor_baseline: dict | None
+ raw: dict
+
+ def active_workspaces(self) -> list[ManifestWorkspace]:
+ return [w for w in self.workspaces if w.status != "archived"]
+
+ def find_workspace(self, workspace_id: str) -> ManifestWorkspace | None:
+ """Match a managed workspace by its declared identifier only."""
+ for workspace in self.workspaces:
+ if workspace.workspace_id == workspace_id:
+ return workspace
+ return None
+
+
+def _optional_str(value: Any) -> str:
+ return value.strip() if isinstance(value, str) else ""
+
+
+def _record(value: Any) -> dict:
+ return value if isinstance(value, dict) else {}
+
+
+def _normalize_workspace_entry(value: Any, index: int, project_dir: str) -> ManifestWorkspace:
+ source = _record(value)
+ workspace_id = _optional_str(source.get("workspace_id"))
+ workspace_path = _optional_str(source.get("workspace_path"))
+ if not workspace_id or not workspace_path:
+ raise ManifestError(f"workspaces[{index}] requires workspace_id and workspace_path")
+ resolved = Path(workspace_path)
+ if not resolved.is_absolute():
+ resolved = Path(project_dir) / resolved
+ try:
+ canonical = resolved.resolve()
+ canonical.relative_to(Path(project_dir).resolve())
+ except ValueError:
+ raise ManifestError(
+ f"workspaces[{index}] workspace_path escapes the project root: {workspace_path}"
+ ) from None
+ except RuntimeError as exc:
+ # e.g. a symlink loop inside the spelled path: invalid manifest
+ # input, never a traceback.
+ raise ManifestError(
+ f"workspaces[{index}] workspace_path cannot be resolved: {workspace_path}"
+ ) from exc
+ status = source.get("status")
+ if not isinstance(status, str) or status not in _WORKSPACE_STATUSES:
+ status = "not_started"
+ start_step = _optional_str(source.get("start_step")) or "Synth"
+ end_step = _optional_str(source.get("end_step")) or "Harden"
+ for step_name, field_name in ((start_step, "start_step"), (end_step, "end_step")):
+ if step_name not in MANIFEST_FLOW_STEPS:
+ raise ManifestError(
+ f"workspaces[{index}] {field_name} is not on the canonical flow chain: {step_name}"
+ )
+ if MANIFEST_FLOW_STEPS.index(start_step) > MANIFEST_FLOW_STEPS.index(end_step):
+ raise ManifestError(
+ f"workspaces[{index}] flow range is reversed: {start_step} -> {end_step}"
+ )
+ return ManifestWorkspace(
+ workspace_id=workspace_id,
+ workspace_path=str(canonical),
+ start_step=start_step,
+ end_step=end_step,
+ status=status,
+ parameter_patch=_record(source.get("parameter_patch")),
+ raw=dict(source),
+ )
+
+
+def _validate_mpc(value: Any) -> None:
+ """Mirror the GUI parser's mpc rules: null or a well-formed MPC record."""
+ if value is None:
+ return
+ source = _record(value)
+ if not source:
+ raise ManifestError("invalid project manifest: mpc must be an object or null")
+ resource_id = _optional_str(source.get("resource_id"))
+ if not resource_id.startswith("mpc:") or len(resource_id) == 4:
+ raise ManifestError("invalid project manifest: mpc.resource_id must be an MPC id")
+ for field_name in ("display_name", "installed_version", "path", "spec_path"):
+ if not _optional_str(source.get(field_name)):
+ raise ManifestError(f"invalid project manifest: mpc.{field_name} is required")
+ mpc_path = source["path"].rstrip("/")
+ if source["spec_path"] != f"{mpc_path}/spec/spec.json.in":
+ raise ManifestError(
+ "invalid project manifest: mpc.spec_path must reference spec/spec.json.in"
+ )
+ design = _record(source.get("design"))
+ index = design.get("index")
+ if (
+ not design
+ or isinstance(index, bool) # a JSON boolean is not an index (True == 1 in Python)
+ or not isinstance(index, (int, float))
+ # The GUI accepts integral numbers (0.0). is_integer() applies to
+ # floats only: float(huge_int) raises OverflowError, and ints are
+ # integral by construction.
+ or (isinstance(index, float) and not index.is_integer())
+ or index < 0
+ or not _optional_str(design.get("design_name"))
+ ):
+ raise ManifestError(
+ "invalid project manifest: mpc.design requires a non-negative index and design_name"
+ )
+ if not isinstance(source.get("core_template"), dict):
+ raise ManifestError("invalid project manifest: mpc.core_template must be an object")
+
+
+def load_manifest(project_dir: str) -> ProjectManifest:
+ """Load and tolerantly normalize ``/project.json``.
+
+ Mirrors the GUI parser's contract: schema_version 1 and a workspaces
+ array are required, everything else is default-filled. Raises
+ ManifestError on parse failure, root_path mismatch, or a workspace
+ path outside the project root.
+ """
+ path = os.path.join(project_dir, MANIFEST_FILENAME)
+ try:
+ with open(path, encoding="utf-8") as f:
+ source = json.load(f)
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
+ raise ManifestError(f"invalid project manifest: {path}: {exc}") from exc
+
+ if not isinstance(source, dict):
+ raise ManifestError(f"invalid project manifest: {path}: top level must be an object")
+ schema_version = source.get("schema_version")
+ # A JSON boolean is not the schema version (True == 1 in Python); the
+ # GUI parser rejects it, and the CLI must not open what the GUI cannot.
+ if isinstance(schema_version, bool) or schema_version != 1:
+ raise ManifestError("invalid project manifest: schema_version 1 is required")
+ raw_workspaces = source.get("workspaces")
+ if not isinstance(raw_workspaces, list):
+ raise ManifestError("invalid project manifest: workspaces must be an array")
+
+ root_path = _optional_str(source.get("root_path"))
+ if not root_path:
+ raise ManifestError("invalid project manifest: root_path is required")
+ if os.path.realpath(root_path) != os.path.realpath(project_dir):
+ raise ManifestError(
+ f"invalid project manifest: root_path {root_path} does not match {project_dir}"
+ )
+ design_name = _optional_str(source.get("design_name"))
+ if not design_name:
+ raise ManifestError("invalid project manifest: design_name is required")
+
+ name = _optional_str(source.get("name")) or os.path.basename(project_dir) or "project"
+ base_design = _record(source.get("base_design"))
+ base_design = {**base_design, "parameters": _record(base_design.get("parameters"))}
+ # Mirror the GUI parser: primary defaults to "timing", directions keep
+ # only maximize/minimize entries from the source (no default fill).
+ objectives_raw = _record(source.get("objectives"))
+ objectives = dict(objectives_raw)
+ objectives["primary"] = _optional_str(objectives_raw.get("primary")) or "timing"
+ objectives["directions"] = {
+ key: value
+ for key, value in _record(objectives_raw.get("directions")).items()
+ if value in ("maximize", "minimize")
+ }
+ qor_baseline_raw = _record(source.get("qor_baseline"))
+ qor_baseline = None
+ if _optional_str(qor_baseline_raw.get("workspace_id")):
+ qor_baseline = {
+ "workspace_id": qor_baseline_raw["workspace_id"],
+ "reason": _optional_str(qor_baseline_raw.get("reason")) or "Project QoR baseline",
+ }
+
+ _validate_mpc(source.get("mpc"))
+
+ return ProjectManifest(
+ project_dir=project_dir,
+ path=path,
+ project_id=_optional_str(source.get("project_id")) or f"proj_{_slugify(name)}",
+ name=name,
+ design_name=design_name,
+ base_design=base_design,
+ objectives=objectives,
+ workspaces=tuple(
+ _normalize_workspace_entry(entry, index, project_dir)
+ for index, entry in enumerate(raw_workspaces)
+ ),
+ qor_baseline=qor_baseline,
+ raw=source,
+ )
+
+
+def find_manifest(project_dir: str) -> str | None:
+ """The manifest path when the entry lexically exists.
+
+ Lexical presence, not readability: a directory, symlink loop, or
+ dangling symlink still counts as PRESENT so ``load_manifest`` fails
+ loud (manifest_invalid) instead of silently demoting the project to
+ the virgin or legacy layout.
+ """
+ path = os.path.join(project_dir, MANIFEST_FILENAME)
+ return path if os.path.lexists(path) else None
+
+
+def has_legacy_runs_layout(project_dir: str) -> bool:
+ runs_dir = os.path.join(project_dir, "runs")
+ if not os.path.isdir(runs_dir):
+ return False
+ try:
+ return any(os.path.isdir(os.path.join(runs_dir, entry)) for entry in os.listdir(runs_dir))
+ except OSError:
+ return False
+
+
+def classify_project(project_dir: str) -> str:
+ """Classify a project directory: manifest | legacy | virgin."""
+ if find_manifest(project_dir) is not None:
+ return "manifest"
+ if has_legacy_runs_layout(project_dir):
+ return "legacy"
+ return "virgin"
+
+
+def assemble_config(manifest: ProjectManifest, workspace: ManifestWorkspace | None) -> dict:
+ """Flatten the manifest into a parameter payload (lowest precedence layer).
+
+ ``base_design.parameters`` plus the workspace's ``parameter_patch`` form
+ the base layer beneath project ecc.toml and --set overrides.
+ """
+ parameters = dict(manifest.base_design.get("parameters") or {})
+ if workspace is not None:
+ for key, change in workspace.parameter_patch.items():
+ parameters[key] = (
+ change["to"] if isinstance(change, dict) and "to" in change else change
+ )
+ if manifest.design_name and not _optional_str(parameters.get("design")):
+ parameters["design"] = manifest.design_name
+ rtl_list = manifest.base_design.get("rtl_list")
+ if not isinstance(rtl_list, list):
+ rtl_list = []
+ return {
+ "pdk": _optional_str(manifest.base_design.get("pdk")),
+ "pdk_root": _optional_str(manifest.base_design.get("pdk_root")),
+ "design_name": manifest.design_name,
+ "top_module": _optional_str(manifest.base_design.get("top_module")),
+ "clock": _optional_str(manifest.base_design.get("clock")),
+ "rtl_list": [item for item in rtl_list if isinstance(item, str)],
+ "origin_verilog": _optional_str(manifest.base_design.get("origin_verilog")),
+ "origin_def": _optional_str(manifest.base_design.get("origin_def")),
+ "netlist": _optional_str(manifest.base_design.get("netlist")),
+ "golden_netlist": _optional_str(manifest.base_design.get("golden_netlist")),
+ "sdc": _optional_str(manifest.base_design.get("sdc")),
+ "spef": _optional_str(manifest.base_design.get("spef")),
+ "parameters": parameters,
+ }
+
+
+def resolved_base_parameters(cfg) -> dict:
+ """The ecc.toml-resolved base_design.parameters for a generated manifest.
+
+ GUI-flat vocabulary: identity fields plus the [params] overrides,
+ projected through the geometry converter so positional values surface
+ as the wizard's aliases (die_width, utilitization, margin, ...).
+ --set values are run-scoped and never included.
+ """
+ canonical: dict = {
+ "design": cfg.design_name,
+ "top_module": cfg.design_top,
+ "clock": cfg.design_clock_port,
+ "frequency_max": cfg.design_frequency_mhz,
+ }
+ if cfg.params_overrides:
+ from chipcompiler.data.parameter_schema import (
+ build_backend_overrides,
+ resolve_parameters,
+ )
+
+ resolved, _ = resolve_parameters(toml_overrides=cfg.params_overrides)
+ canonical.update(build_backend_overrides(resolved))
+
+ from chipcompiler.data.parameter_keys import parameters_to_geometry
+
+ flat = parameters_to_geometry(canonical)
+ # Exclusive GUI-flat shape: geometry lives only in the aliases —
+ # the canonical die/core subtrees are consumed, not duplicated.
+ # Non-positional members (e.g. aspect_ratio) hoist to flat top-level
+ # keys; positional members are covered by the aliases.
+ for subtree_name in ("die", "core"):
+ subtree = flat.pop(subtree_name, None)
+ if not isinstance(subtree, dict):
+ continue
+ for member, value in subtree.items():
+ if member in ("size", "utilitization", "margin"):
+ continue
+ flat.setdefault(member, value)
+ return flat
+
+
+def base_design_from_config(cfg, pdk_root: str) -> dict:
+ """The base_design document for a generated manifest.
+
+ Identity and sources come from the ecc.toml-resolved config with the
+ DECLARED project source spellings preserved: ``rtl_list`` verbatim,
+ and ``origin_verilog`` when the single source is plain RTL (empty for
+ a filelist source; the document builder drops empty keys). Parameters
+ are the GUI-flat projection. Shared by virgin generation and first
+ migration so the two writers cannot drift.
+ """
+ from chipcompiler.utility.filelist import FILELIST_SUFFIXES
+
+ first_rtl = cfg.design_rtl[0] if cfg.design_rtl else ""
+ suffix = os.path.splitext(first_rtl)[1].lower()
+ origin_verilog = first_rtl if first_rtl and suffix not in FILELIST_SUFFIXES else ""
+ return {
+ "pdk": cfg.pdk_name,
+ "pdk_root": pdk_root,
+ "top_module": cfg.design_top,
+ "clock": cfg.design_clock_port,
+ "rtl_list": cfg.design_rtl,
+ "origin_verilog": cfg.design_rtl[0] if origin_verilog else "",
+ "origin_def": cfg.design_def,
+ "netlist": cfg.design_netlist,
+ "golden_netlist": cfg.design_golden_netlist,
+ "sdc": cfg.design_sdc,
+ "spef": cfg.design_spef,
+ "parameters": resolved_base_parameters(cfg),
+ }
+
+
+def _slugify(value: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
+ return slug or "project"
diff --git a/chipcompiler/project/manifest_write.py b/chipcompiler/project/manifest_write.py
new file mode 100644
index 00000000..cf1364d0
--- /dev/null
+++ b/chipcompiler/project/manifest_write.py
@@ -0,0 +1,396 @@
+#!/usr/bin/env python
+
+"""Manifest write, mutation, and registration operations for the CLI.
+
+All ``project.json`` writes go through one read-modify-write helper so
+status write-back and migration entry-append share the same atomicity
+story. Loading and normalization live in
+chipcompiler.project.manifest; this module imports from it, never
+the reverse.
+
+Like manifest.py, this module sits on the CLI startup path (imported by
+run dispatch and migration flows): keep module-level imports cheap — no
+chipcompiler.data imports here.
+"""
+
+import json
+import logging
+import os
+import tempfile
+from copy import deepcopy
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from chipcompiler.project.manifest import (
+ _CANONICAL_TO_MANIFEST_STEP,
+ MANIFEST_FILENAME,
+ PRESET_MANIFEST_RANGE,
+ ManifestError,
+ _record,
+ _slugify,
+ base_design_from_config,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_OBJECTIVES = {
+ "primary": "timing",
+ "directions": {
+ "wns": "maximize",
+ "tns": "maximize",
+ "area": "minimize",
+ "drc_count": "minimize",
+ "lvs_count": "minimize",
+ "power": "minimize",
+ },
+}
+
+
+def _now_iso() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def manifest_workspace_entry(
+ workspace_id: str,
+ *,
+ name: str,
+ workspace_path: str,
+ start_step: str,
+ end_step: str,
+ status: str,
+ now: str,
+) -> dict:
+ """One complete schema-v1 workspaces[] entry, every field materialized.
+
+ The single builder for generated manifests and migration previews, so
+ the previewed entry and the applied entry are the same object shape.
+ """
+ return {
+ "workspace_id": workspace_id,
+ "name": name,
+ "workspace_path": workspace_path,
+ "source_workspace_id": None,
+ "branch_from": None,
+ "start_step": start_step,
+ "end_step": end_step,
+ "status": status,
+ "created_at": now,
+ "updated_at": now,
+ "parameter_patch": {},
+ "metrics_summary": {},
+ "step_metrics": {},
+ }
+
+
+def build_manifest_document(
+ project_dir: str,
+ *,
+ design_name: str,
+ base_design: dict,
+ workspace_id: str,
+ workspace_path: str,
+ start_step: str,
+ end_step: str,
+ status: str = "running",
+) -> dict:
+ """Assemble a schema-v1 manifest for a virgin project's first run."""
+ now = _now_iso()
+ document = build_project_document(
+ project_dir,
+ design_name=design_name,
+ base_design=base_design,
+ now=now,
+ )
+ document["workspaces"] = [
+ manifest_workspace_entry(
+ workspace_id,
+ name=design_name,
+ workspace_path=workspace_path,
+ start_step=start_step,
+ end_step=end_step,
+ status=status,
+ now=now,
+ )
+ ]
+ document["qor_baseline"] = {
+ "workspace_id": workspace_id,
+ "reason": "Default project QoR baseline",
+ }
+ return document
+
+
+def build_project_document(
+ project_dir: str,
+ *,
+ design_name: str,
+ base_design: dict,
+ name: str | None = None,
+ now: str | None = None,
+ mpc: dict | None = None,
+) -> dict[str, Any]:
+ """Assemble a schema-v1 Project Manifest without a Workspace."""
+ timestamp = now or _now_iso()
+ project_name = name or os.path.basename(os.path.normpath(project_dir)) or "project"
+ return {
+ "schema_version": 1,
+ "project_id": f"proj_{_slugify(project_name)}",
+ "name": project_name,
+ "design_name": design_name,
+ "description": "",
+ "root_path": project_dir,
+ "created_at": timestamp,
+ "updated_at": timestamp,
+ "base_design": {
+ **{key: value for key, value in base_design.items() if key != "parameters" and value},
+ "parameters": _record(base_design.get("parameters")),
+ "rtl_list": [
+ item for item in base_design.get("rtl_list") or [] if isinstance(item, str)
+ ],
+ },
+ "objectives": json.loads(json.dumps(DEFAULT_OBJECTIVES)),
+ "workspaces": [],
+ "mpc": deepcopy(mpc),
+ "best_workspace": None,
+ "qor_baseline": None,
+ }
+
+
+def write_manifest_if_absent(project_dir: str, document: dict) -> bool:
+ """Write the manifest only when it does not exist (virgin generation race).
+
+ Fully written and fsynced at a temp path, then linked into place:
+ readers never see a partial file, and a concurrent creator wins the
+ link — ours is discarded and the caller continues read-only.
+ """
+ path = os.path.join(project_dir, MANIFEST_FILENAME)
+ content = json.dumps(document, indent=2) + "\n"
+ tmp_path = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ "w",
+ dir=project_dir,
+ delete=False,
+ prefix=f".{MANIFEST_FILENAME}.",
+ suffix=".tmp",
+ encoding="utf-8",
+ ) as f:
+ tmp_path = f.name
+ f.write(content)
+ f.flush()
+ os.fsync(f.fileno())
+ # Mode stays the tempfile default (0600), matching json_write's
+ # convention for newly created state files.
+ os.link(tmp_path, path)
+ return True
+ except FileExistsError:
+ return False
+ except OSError as exc:
+ logger.warning("manifest write failed: %s: %s", path, exc)
+ return False
+ finally:
+ if tmp_path is not None:
+ Path(tmp_path).unlink(missing_ok=True)
+
+
+def _read_manifest_document(path: str):
+ try:
+ with open(path, encoding="utf-8") as f:
+ document = json.load(f)
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
+ return None
+ return document if isinstance(document, dict) else None
+
+
+def update_manifest(project_dir: str, mutator) -> bool:
+ """Read-modify-write the manifest atomically (locked re-read + patch + replace).
+
+ The whole read-modify-replace runs under ``.manifest.lock`` (flock):
+ two cooperating writers can no longer both complete the fresh read
+ before either replaces, so neither loses the other's update. The
+ mutator receives the parsed document and edits it in place. When an
+ unrelated change lands between the read and the write, the mutator is
+ re-applied to the freshest document instead of overwriting the change.
+ Project-level fields (including updated_at) are owned by the mutator.
+ Returns False (with a warning) when the manifest is missing, unreadable,
+ the lock cannot be taken, or the write fails — callers degrade to a
+ warning, never a run failure.
+ """
+ from chipcompiler.project.locking import flock_file
+
+ path = os.path.join(project_dir, MANIFEST_FILENAME)
+ try:
+ with flock_file(os.path.join(project_dir, ".manifest.lock"), exclusive=True):
+ return _update_manifest_locked(path, mutator)
+ except OSError as exc:
+ # An untakeable lock (e.g. a directory at the lock path) degrades
+ # like any write failure: a warning, never an uncaught exception —
+ # the migration registration path relies on False to roll back.
+ logger.warning("manifest update failed: %s: %s", path, exc)
+ return False
+
+
+def _update_manifest_locked(path: str, mutator) -> bool:
+ base = _read_manifest_document(path)
+ if base is None:
+ logger.warning("manifest update skipped (unreadable): %s", path)
+ return False
+
+ document = deepcopy(base)
+ mutator(document)
+
+ fresh = _read_manifest_document(path)
+ if fresh is not None and fresh != base:
+ # An unrelated edit landed after our read: re-apply the mutator to
+ # the freshest document so the interleaved change survives.
+ document = fresh
+ mutator(document)
+
+ target = Path(path)
+ tmp_path = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ "w",
+ dir=target.parent,
+ delete=False,
+ prefix=f".{target.name}.",
+ suffix=".tmp",
+ encoding="utf-8",
+ ) as f:
+ tmp_path = Path(f.name)
+ json.dump(document, f, indent=2)
+ f.write("\n")
+ f.flush()
+ os.fsync(f.fileno())
+ # Preserve the existing manifest's permissions: mkstemp's 0600 must
+ # not silently narrow a shared project.json.
+ if target.exists():
+ os.chmod(tmp_path, target.stat().st_mode & 0o7777)
+ os.replace(tmp_path, target)
+ return True
+ except OSError as exc:
+ logger.warning("manifest update failed: %s: %s", path, exc)
+ if tmp_path is not None:
+ tmp_path.unlink(missing_ok=True)
+ return False
+
+
+def write_back_workspace_status(project_dir: str, workspace_id: str, status: str) -> bool:
+ """Update one workspace entry's status (and updated_at) after a run."""
+
+ def mutate(document: dict) -> None:
+ for entry in document.get("workspaces", []):
+ if isinstance(entry, dict) and entry.get("workspace_id") == workspace_id:
+ entry["status"] = status
+ entry["updated_at"] = _now_iso()
+
+ return update_manifest(project_dir, mutate)
+
+
+def remove_workspace_registration(project_dir: str, workspace_id: str) -> bool:
+ """Roll back a pre-registration: drop the freshly added entry.
+
+ Used when an overwrite run against an undeclared workspace fails before
+ the replacement is constructed: the restored previous workspace must not
+ be shadowed by a stale ``not_started`` entry this invocation created.
+ """
+
+ def mutate(document: dict) -> None:
+ workspaces = document.get("workspaces")
+ if isinstance(workspaces, list):
+ document["workspaces"] = [
+ entry
+ for entry in workspaces
+ if not (isinstance(entry, dict) and entry.get("workspace_id") == workspace_id)
+ ]
+ document["updated_at"] = _now_iso()
+
+ return update_manifest(project_dir, mutate)
+
+
+def manifest_range_for_flow(cfg, flow_config: dict | None) -> tuple[str, str]:
+ """Return the GUI manifest range for a workspace's effective target."""
+ if isinstance(flow_config, dict) and flow_config.get("start_step"):
+ from chipcompiler.rtl2gds import normalize_flow_step
+
+ start = normalize_flow_step(flow_config["start_step"])
+ end = normalize_flow_step(flow_config.get("end_step") or start)
+ try:
+ return (_CANONICAL_TO_MANIFEST_STEP[start], _CANONICAL_TO_MANIFEST_STEP[end])
+ except KeyError as exc:
+ raise ManifestError(f"unknown workspace flow step: {exc.args[0]}") from None
+ return PRESET_MANIFEST_RANGE.get(cfg.flow_preset, ("Synth", "Harden"))
+
+
+def pre_register_workspace(
+ project_dir: str,
+ *,
+ cfg,
+ pdk_root: str,
+ workspace_id: str,
+ workspace_path: str,
+ flow_config: dict | None,
+) -> str:
+ """Atomically register a fresh managed workspace before filesystem creation.
+
+ Returns ``registered``, ``existing``, ``conflict``, or ``failed``. A
+ workspace entry intentionally contains no input snapshot: copied files and
+ the workspace config are the reproducibility boundary.
+ """
+ try:
+ start_step, end_step = manifest_range_for_flow(cfg, flow_config)
+ except ManifestError:
+ return "failed"
+ now = _now_iso()
+ manifest_path = os.path.join(project_dir, MANIFEST_FILENAME)
+ if not os.path.lexists(manifest_path):
+ document = build_manifest_document(
+ project_dir,
+ design_name=cfg.design_name,
+ base_design=base_design_from_config(cfg, pdk_root),
+ workspace_id=workspace_id,
+ workspace_path=workspace_path,
+ start_step=start_step,
+ end_step=end_step,
+ status="not_started",
+ )
+ if write_manifest_if_absent(project_dir, document):
+ return "registered"
+ # A concurrent creator won the link race: fall through and apply the
+ # same registration mutation under the manifest lock instead of
+ # aborting this run. A genuine I/O failure fails again below.
+
+ outcome = "registered"
+
+ def mutate(document: dict) -> None:
+ nonlocal outcome
+ workspaces = document.get("workspaces")
+ if not isinstance(workspaces, list):
+ outcome = "failed"
+ return
+ for entry in workspaces:
+ if not isinstance(entry, dict) or entry.get("workspace_id") != workspace_id:
+ continue
+ if os.path.realpath(str(entry.get("workspace_path", ""))) == os.path.realpath(
+ workspace_path
+ ):
+ outcome = "existing"
+ else:
+ outcome = "conflict"
+ return
+ workspaces.append(
+ manifest_workspace_entry(
+ workspace_id,
+ name=cfg.design_name,
+ workspace_path=workspace_path,
+ start_step=start_step,
+ end_step=end_step,
+ status="not_started",
+ now=now,
+ )
+ )
+ document["updated_at"] = now
+
+ if not update_manifest(project_dir, mutate):
+ return "failed"
+ return outcome
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
index 2b7702cd..e9bb8e72 100644
--- a/test/engine/test_workspace_configuration.py
+++ b/test/engine/test_workspace_configuration.py
@@ -97,6 +97,26 @@ def fail_refresh(_workspace):
assert read_engineering_snapshot(workspace) == before_snapshot
+def test_read_workspace_configuration_does_not_materialize_missing_home_files(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings)
+ home_file = workspace.directory / "home" / "home.json"
+ log_dir = workspace.directory / "log"
+ home_file.unlink()
+ if log_dir.exists():
+ import shutil
+
+ shutil.rmtree(log_dir)
+
+ read_workspace_configuration_from_directory(workspace.directory)
+
+ assert not home_file.exists()
+ assert not log_dir.exists()
+
+
def test_step_configuration_update_invalidates_only_target_suffix(
tmp_path, minimal_ics55_pdk_factory
):
diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py
index d0703bfd..7f057153 100644
--- a/test/engine/test_workspace_spec.py
+++ b/test/engine/test_workspace_spec.py
@@ -153,6 +153,22 @@ def test_validated_workspace_spec_creates_main_compatible_workspace(
assert reopened.flow.steps()[0]["name"] == "Synthesis"
+def test_workspace_spec_applies_config_target_parameters(tmp_path, minimal_ics55_pdk_factory):
+ from chipcompiler.data import load_workspace
+ from chipcompiler.engine import create_workspace_from_spec
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ spec = deepcopy(payload["workspaceSpec"])
+ spec["parameters"]["cts.skew_bound"] = "0.12"
+
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings)
+ reopened = load_workspace(workspace.directory)
+ cts = json.loads(reopened.config["CTS"].read_text(encoding="utf-8"))
+
+ assert cts["skew_bound"] == "0.12"
+
+
def test_manual_pdk_workspace_reopens_through_main_persistence(tmp_path):
from chipcompiler.data import load_workspace
from chipcompiler.engine import (
@@ -270,3 +286,30 @@ def test_workspace_spec_update_is_atomic_revisioned_and_idempotent(
)
assert conflict.value.code == "revision_conflict"
assert read_engineering_snapshot(repeated) == after
+
+
+def test_workspace_spec_stale_revision_does_not_create_missing_snapshot(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ from chipcompiler.engine import WorkspaceLifecycleError, create_workspace_from_spec
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ target = tmp_path / "workspace"
+ create_workspace_from_spec(target, payload["workspaceSpec"], bindings)
+ snapshot = target / "home" / "engineering-snapshot.json"
+ snapshot.unlink()
+
+ with pytest.raises(WorkspaceLifecycleError) as conflict:
+ from chipcompiler.engine import update_workspace_from_spec
+
+ update_workspace_from_spec(
+ target,
+ 2,
+ payload["workspaceSpec"],
+ bindings,
+ "stale-update",
+ )
+
+ assert conflict.value.code == "revision_conflict"
+ assert not snapshot.exists()
From 932bf873648d849694258dff13e935a95428092f Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 04:58:23 +0800
Subject: [PATCH 17/25] fix(workspace): preserve read-only and error boundaries
---
chipcompiler/data/pdk.py | 21 +++++++++-------
chipcompiler/data/workspace/__init__.py | 1 +
chipcompiler/engine/signoff_export.py | 24 ++++++++++++------
chipcompiler/engine/workspace_lifecycle.py | 5 ++++
test/engine/test_signoff_assessment.py | 2 +-
test/engine/test_workspace_configuration.py | 8 +++---
test/engine/test_workspace_spec.py | 27 +++++++++++++++++++++
7 files changed, 68 insertions(+), 20 deletions(-)
diff --git a/chipcompiler/data/pdk.py b/chipcompiler/data/pdk.py
index febc744f..9d8278b1 100644
--- a/chipcompiler/data/pdk.py
+++ b/chipcompiler/data/pdk.py
@@ -249,6 +249,8 @@ def get_pdk(
pdk_root: str | Path = "",
pdk_config: str | Path = "",
overrides: dict | None = None,
+ *,
+ validate: bool = True,
) -> PDK:
"""
Return the PDK instance based on the given pdk name.
@@ -273,15 +275,16 @@ def get_pdk(
pdk = _builtin_pdk(pdk_name_normalized, pdk_root=pdk_root) or PDK(name=pdk_name_normalized)
overrides = overrides or {}
pdk = apply_pdk_overrides(pdk, overrides)
- pdk.validate()
- errors = []
- for key, label in _OPTIONAL_PATH_LABELS.items():
- if key not in overrides:
- continue
- path = getattr(pdk, key)
- if path and not path.is_file():
- errors.append(f"{label}: {path}")
- _raise_pdk_validation_error(errors)
+ if validate:
+ pdk.validate()
+ errors = []
+ for key, label in _OPTIONAL_PATH_LABELS.items():
+ if key not in overrides:
+ continue
+ path = getattr(pdk, key)
+ if path and not path.is_file():
+ errors.append(f"{label}: {path}")
+ _raise_pdk_validation_error(errors)
return pdk
diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py
index a3013dc5..c8d7e5ee 100644
--- a/chipcompiler/data/workspace/__init__.py
+++ b/chipcompiler/data/workspace/__init__.py
@@ -1233,6 +1233,7 @@ def load_workspace(directory: str | Path, *, read_only: bool = False) -> Workspa
pdk_name=parameters.data.get("pdk", ""),
pdk_root=parameters.data.get("pdk_root", ""),
pdk_config=parameters.data.get("pdk_config", ""),
+ validate=not read_only,
)
sdc_path = list(origin_dir.rglob("*.sdc"))
if len(sdc_path) > 0:
diff --git a/chipcompiler/engine/signoff_export.py b/chipcompiler/engine/signoff_export.py
index 84b23842..19afb6b1 100644
--- a/chipcompiler/engine/signoff_export.py
+++ b/chipcompiler/engine/signoff_export.py
@@ -59,11 +59,16 @@ def export_signoff_package_archive(
raise SignoffExportError("additional file entries require archivePath")
if not isinstance(content, str):
raise SignoffExportError("additional file entries require string content")
- path = _additional_file_path(package_dir, archive_path)
- path.parent.mkdir(parents=True, exist_ok=True)
- if _has_symlink_parent(package_dir, path):
- raise SignoffExportError("additional file path contains a symlink")
- path.write_text(content, encoding="utf-8")
+ try:
+ path = _additional_file_path(package_dir, archive_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if _has_symlink_parent(package_dir, path):
+ raise SignoffExportError("additional file path contains a symlink")
+ path.write_text(content, encoding="utf-8")
+ except SignoffExportError:
+ raise
+ except (OSError, ValueError, TypeError) as exc:
+ raise SignoffExportError(str(exc)) from exc
archive = package_dir.with_suffix(".tar.gz")
import tarfile
@@ -88,10 +93,15 @@ def export_signoff_package_archive(
def _additional_file_path(package_dir: Path, archive_path: str) -> Path:
+ if not archive_path or "\x00" in archive_path:
+ raise SignoffExportError("additional file path must name a relative file")
relative = Path(archive_path)
- if not archive_path or relative.is_absolute() or ".." in relative.parts:
+ if relative == Path(".") or relative.is_absolute() or ".." in relative.parts:
raise SignoffExportError("additional file path must stay inside the signoff package")
- destination = (package_dir / relative).resolve()
+ try:
+ destination = (package_dir / relative).resolve()
+ except (OSError, ValueError) as exc:
+ raise SignoffExportError("additional file path must name a relative file") from exc
if not destination.is_relative_to(package_dir.resolve()):
raise SignoffExportError("additional file path must stay inside the signoff package")
return destination
diff --git a/chipcompiler/engine/workspace_lifecycle.py b/chipcompiler/engine/workspace_lifecycle.py
index b8c5e073..726c7cd6 100644
--- a/chipcompiler/engine/workspace_lifecycle.py
+++ b/chipcompiler/engine/workspace_lifecycle.py
@@ -253,9 +253,14 @@ def _update_workspace_from_spec(
if command_id and _command_retry_matches(target, command_id, fingerprint):
return _load_committed_workspace(target)
current = _load_committed_workspace(target)
+ snapshot_path = target / "home" / "engineering-snapshot.json"
try:
snapshot = read_engineering_snapshot(current)
except EngineeringSnapshotError as exc:
+ if snapshot_path.exists() or snapshot_path.is_symlink():
+ raise WorkspaceLifecycleError(
+ "workspace_invalid", "Invalid Engineering Snapshot"
+ ) from exc
if expected_workspace_revision != 1:
raise WorkspaceLifecycleError(
"revision_conflict",
diff --git a/test/engine/test_signoff_assessment.py b/test/engine/test_signoff_assessment.py
index e3ea8fb2..d237b3a8 100644
--- a/test/engine/test_signoff_assessment.py
+++ b/test/engine/test_signoff_assessment.py
@@ -44,7 +44,7 @@ def test_signoff_additional_file_path_rejects_escape(tmp_path):
package = tmp_path / "package"
package.mkdir()
- for value in ("/tmp/escape", "../escape", ""):
+ for value in ("/tmp/escape", "../escape", "", ".", "bad\x00path"):
try:
_additional_file_path(package, value)
except SignoffExportError:
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
index e9bb8e72..e98e3126 100644
--- a/test/engine/test_workspace_configuration.py
+++ b/test/engine/test_workspace_configuration.py
@@ -100,6 +100,8 @@ def fail_refresh(_workspace):
def test_read_workspace_configuration_does_not_materialize_missing_home_files(
tmp_path, minimal_ics55_pdk_factory
):
+ import shutil
+
spec, bindings = _workspace_spec_fixture()
bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings)
@@ -107,12 +109,12 @@ def test_read_workspace_configuration_does_not_materialize_missing_home_files(
log_dir = workspace.directory / "log"
home_file.unlink()
if log_dir.exists():
- import shutil
-
shutil.rmtree(log_dir)
+ shutil.rmtree(Path(bindings["pdk"]["root"]))
- read_workspace_configuration_from_directory(workspace.directory)
+ configuration = read_workspace_configuration_from_directory(workspace.directory)
+ assert configuration["workspaceSpec"]["pdk"]["familyId"] == "ics55"
assert not home_file.exists()
assert not log_dir.exists()
diff --git a/test/engine/test_workspace_spec.py b/test/engine/test_workspace_spec.py
index 7f057153..e8782024 100644
--- a/test/engine/test_workspace_spec.py
+++ b/test/engine/test_workspace_spec.py
@@ -313,3 +313,30 @@ def test_workspace_spec_stale_revision_does_not_create_missing_snapshot(
assert conflict.value.code == "revision_conflict"
assert not snapshot.exists()
+
+
+def test_workspace_spec_rejects_invalid_existing_snapshot(tmp_path, minimal_ics55_pdk_factory):
+ from chipcompiler.engine import (
+ WorkspaceLifecycleError,
+ create_workspace_from_spec,
+ update_workspace_from_spec,
+ )
+
+ payload, bindings = _shared_fixture("valid.json")
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ target = tmp_path / "workspace"
+ create_workspace_from_spec(target, payload["workspaceSpec"], bindings)
+ snapshot = target / "home" / "engineering-snapshot.json"
+ snapshot.write_text('{"schemaVersion": 2}', encoding="utf-8")
+
+ with pytest.raises(WorkspaceLifecycleError) as invalid:
+ update_workspace_from_spec(
+ target,
+ 1,
+ payload["workspaceSpec"],
+ bindings,
+ "invalid-snapshot-update",
+ )
+
+ assert invalid.value.code == "workspace_invalid"
+ assert snapshot.read_text(encoding="utf-8") == '{"schemaVersion": 2}'
From 591a40a9970ccdd0c82c2297d588ec73eecbb6dd Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 05:10:42 +0800
Subject: [PATCH 18/25] refactor(workspace): isolate hydration helpers
---
chipcompiler/data/checklist.py | 12 ++
chipcompiler/data/workspace/__init__.py | 134 ++------------------
chipcompiler/data/workspace/loader.py | 129 +++++++++++++++++++
chipcompiler/tools/ecc/checklist_render.py | 13 ++
chipcompiler/tools/ecc/signoff_checklist.py | 17 +--
5 files changed, 166 insertions(+), 139 deletions(-)
create mode 100644 chipcompiler/data/workspace/loader.py
create mode 100644 chipcompiler/tools/ecc/checklist_render.py
diff --git a/chipcompiler/data/checklist.py b/chipcompiler/data/checklist.py
index 2356a447..0f7b80d2 100644
--- a/chipcompiler/data/checklist.py
+++ b/chipcompiler/data/checklist.py
@@ -46,6 +46,18 @@ def __init__(self, path: Path | str):
self.path = Path(path)
self.data = self._load_current_data()
+ @classmethod
+ def from_items(cls, path: Path | str, items) -> dict:
+ """Build normalized checklist data without reading or writing a file."""
+ checklist = cls.__new__(cls)
+ checklist.path = Path(path)
+ checklist.data = checklist._default_data()
+ checklist.data["checklist"] = [
+ cls._normalize_item(item) for item in items if isinstance(item, dict)
+ ]
+ cls._refresh_summary(checklist.data)
+ return checklist.data
+
@staticmethod
def _timestamp() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
diff --git a/chipcompiler/data/workspace/__init__.py b/chipcompiler/data/workspace/__init__.py
index c8d7e5ee..f3a96b45 100644
--- a/chipcompiler/data/workspace/__init__.py
+++ b/chipcompiler/data/workspace/__init__.py
@@ -14,16 +14,20 @@
from ..parameter import (
Parameters,
get_parameters,
- load_parameter,
reload_parameter,
save_parameter,
update_parameters,
)
+from ..parameter import (
+ load_parameter as load_parameter,
+)
from ..pdk import PDK, get_pdk
from ..step import StateEnum, StepEnum
from ..workspace_config import (
- legacy_parameters_fallback,
- migrate_legacy_parameters,
+ legacy_parameters_fallback as legacy_parameters_fallback,
+)
+from ..workspace_config import (
+ migrate_legacy_parameters as migrate_legacy_parameters,
)
from ..workspace_config import (
workspace_config_path as workspace_config_toml_path,
@@ -1190,129 +1194,9 @@ def _persisted_golden_verilog(workspace_dir: Path) -> tuple[Path | None, bool]:
def load_workspace(directory: str | Path, *, read_only: bool = False) -> Workspace:
- workspace_dir = Path(directory).expanduser().resolve()
- origin_dir = workspace_dir / "origin"
- home_dir = workspace_dir / "home"
- if not workspace_dir.exists():
- return None
-
- if not read_only:
- migrate_legacy_parameters(workspace_dir)
-
- # create workspace instance
- workspace = Workspace()
- workspace.directory = workspace_dir
- if not read_only:
- migrate_workspace_config_filenames(workspace_dir)
- workspace.config = build_workspace_config_paths(workspace)
-
- config_path = workspace_config_toml_path(workspace_dir)
- legacy_path = home_dir / "parameters.json"
- if config_path.is_symlink():
- # A symlinked canonical config would make the workspace execute
- # with external parameters it does not own: reject it the same way
- # the save path refuses to write through a symlink.
- from chipcompiler.data.workspace_config import WorkspaceConfigError
-
- raise WorkspaceConfigError(f"workspace config is a symlink: {config_path}")
- parameters = load_parameter(workspace_config_toml_path(workspace_dir))
- if len(parameters.data) <= 0 and not config_path.exists() and legacy_path.exists():
- # Migration was deferred (e.g. read-only dir): fall back to the
- # normalized in-memory copy so the workspace still opens. When the
- # TOML exists it wins unconditionally — a malformed config never
- # silently falls back to stale JSON.
- fallback = legacy_parameters_fallback(workspace_dir)
- if fallback:
- parameters.data = fallback
- if len(parameters.data) <= 0:
- return None
-
- workspace.parameters = parameters
+ from .loader import load_workspace as hydrate_workspace
- pdk = get_pdk(
- pdk_name=parameters.data.get("pdk", ""),
- pdk_root=parameters.data.get("pdk_root", ""),
- pdk_config=parameters.data.get("pdk_config", ""),
- validate=not read_only,
- )
- sdc_path = list(origin_dir.rglob("*.sdc"))
- if len(sdc_path) > 0:
- pdk.sdc = sdc_path[0]
- spef_path = list(origin_dir.rglob("*.spef"))
- if len(spef_path) > 0:
- pdk.spef = spef_path[0]
-
- # update lef and lib paths based on config
- from chipcompiler.utility import json_read
-
- db_json = json_read(workspace.config.get("db", ""))
- if db_json.get("INPUT", {}).get("tech_lef_path", "") != "":
- pdk.tech = Path(db_json.get("INPUT", {}).get("tech_lef_path", ""))
- if db_json.get("INPUT", {}).get("lef_paths", []) != []:
- pdk.lefs = [Path(path) for path in db_json.get("INPUT", {}).get("lef_paths", [])]
- if db_json.get("INPUT", {}).get("lib_path", []) != []:
- pdk.libs = [Path(path) for path in db_json.get("INPUT", {}).get("lib_path", [])]
- workspace.pdk = pdk
-
- # update config
- workspace.design.name = parameters.data.get("design", "")
- workspace.design.top_module = parameters.data.get("top_module", "")
- def_path = list(origin_dir.rglob("*.def"))
- def_gz_path = list(origin_dir.rglob("*.def.gz"))
- if len(def_path) > 0:
- workspace.design.origin_def = def_path[0]
- if len(def_gz_path) > 0:
- workspace.design.origin_def = def_gz_path[0]
-
- # The golden netlist path is persisted in the first flow step's info at
- # creation; trust it over the golden_* filename convention so a primary
- # netlist whose name merely starts with "golden_" keeps its role. Only
- # legacy ledgers without step info fall back to the filename convention.
- golden, golden_declared = _persisted_golden_verilog(workspace_dir)
- if golden is None and not golden_declared:
- golden_paths = list(origin_dir.rglob("golden_*.v")) + list(
- origin_dir.rglob("golden_*.v.gz")
- )
- golden = golden_paths[0] if golden_paths else None
-
- verilog_path = [path for path in origin_dir.rglob("*.v") if path != golden]
- verilog_gz_path = [path for path in origin_dir.rglob("*.v.gz") if path != golden]
- if len(verilog_path) > 0:
- workspace.design.origin_verilog = verilog_path[0]
- if len(verilog_gz_path) > 0:
- workspace.design.origin_verilog = verilog_gz_path[0]
-
- if golden is not None:
- workspace.design.golden_verilog = golden
-
- filelist_path = origin_dir / "filelist"
- if filelist_path.exists():
- workspace.design.input_filelist = filelist_path
-
- # set home data
- workspace.flow.path = home_dir / "flow.json"
- if read_only:
- workspace.home.path = home_dir / "home.json"
- home_data = json_read(workspace.home.path)
- workspace.home.data = home_data if isinstance(home_data, dict) else {}
- workspace.logger = Logger(name=parameters.data["design"])
- else:
- home_dir.mkdir(parents=True, exist_ok=True)
- workspace.config["dir"].mkdir(parents=True, exist_ok=True)
- workspace.home.init(path=home_dir / "home.json")
- workspace.home.set_flow(workspace.flow.path)
- workspace.home.set_checklist(home_dir / "checklist.json")
- workspace.home.set_parameters(workspace.parameters.path)
-
- # create logger first (needed for copy operations)
- workspace.logger = create_logger(
- name=parameters.data["design"], log_dir=workspace_dir / "log"
- )
-
- log_workspace(workspace)
- log_parameters(workspace)
-
- return workspace
+ return hydrate_workspace(directory, read_only=read_only)
def log_workspace(workspace: Workspace):
diff --git a/chipcompiler/data/workspace/loader.py b/chipcompiler/data/workspace/loader.py
new file mode 100644
index 00000000..46bf1d2f
--- /dev/null
+++ b/chipcompiler/data/workspace/loader.py
@@ -0,0 +1,129 @@
+"""Hydrate a Workspace from committed files without mixing migration policy."""
+
+from pathlib import Path
+from typing import Any
+
+from chipcompiler.utility import Logger, create_logger, json_read
+
+from ..parameter import load_parameter
+from ..pdk import get_pdk
+from ..workspace_config import (
+ legacy_parameters_fallback,
+ migrate_legacy_parameters,
+)
+from ..workspace_config import (
+ workspace_config_path as workspace_config_toml_path,
+)
+
+
+def load_workspace(directory: str | Path, *, read_only: bool = False) -> Any:
+ from . import (
+ Workspace,
+ _persisted_golden_verilog,
+ build_workspace_config_paths,
+ log_parameters,
+ log_workspace,
+ migrate_workspace_config_filenames,
+ )
+
+ workspace_dir = Path(directory).expanduser().resolve()
+ origin_dir = workspace_dir / "origin"
+ home_dir = workspace_dir / "home"
+ if not workspace_dir.exists():
+ return None
+
+ if not read_only:
+ migrate_legacy_parameters(workspace_dir)
+
+ workspace = Workspace()
+ workspace.directory = workspace_dir
+ if not read_only:
+ migrate_workspace_config_filenames(workspace_dir)
+ workspace.config = build_workspace_config_paths(workspace)
+
+ config_path = workspace_config_toml_path(workspace_dir)
+ legacy_path = home_dir / "parameters.json"
+ if config_path.is_symlink():
+ from chipcompiler.data.workspace_config import WorkspaceConfigError
+
+ raise WorkspaceConfigError(f"workspace config is a symlink: {config_path}")
+ parameters = load_parameter(config_path)
+ if len(parameters.data) <= 0 and not config_path.exists() and legacy_path.exists():
+ fallback = legacy_parameters_fallback(workspace_dir)
+ if fallback:
+ parameters.data = fallback
+ if len(parameters.data) <= 0:
+ return None
+
+ workspace.parameters = parameters
+ pdk = get_pdk(
+ pdk_name=parameters.data.get("pdk", ""),
+ pdk_root=parameters.data.get("pdk_root", ""),
+ pdk_config=parameters.data.get("pdk_config", ""),
+ validate=not read_only,
+ )
+ sdc_path = list(origin_dir.rglob("*.sdc"))
+ if sdc_path:
+ pdk.sdc = sdc_path[0]
+ spef_path = list(origin_dir.rglob("*.spef"))
+ if spef_path:
+ pdk.spef = spef_path[0]
+
+ db_json = json_read(workspace.config.get("db", ""))
+ if db_json.get("INPUT", {}).get("tech_lef_path", "") != "":
+ pdk.tech = Path(db_json["INPUT"]["tech_lef_path"])
+ if db_json.get("INPUT", {}).get("lef_paths", []) != []:
+ pdk.lefs = [Path(path) for path in db_json["INPUT"]["lef_paths"]]
+ if db_json.get("INPUT", {}).get("lib_path", []) != []:
+ pdk.libs = [Path(path) for path in db_json["INPUT"]["lib_path"]]
+ workspace.pdk = pdk
+
+ workspace.design.name = parameters.data.get("design", "")
+ workspace.design.top_module = parameters.data.get("top_module", "")
+ def_path = list(origin_dir.rglob("*.def"))
+ def_gz_path = list(origin_dir.rglob("*.def.gz"))
+ if def_path:
+ workspace.design.origin_def = def_path[0]
+ if def_gz_path:
+ workspace.design.origin_def = def_gz_path[0]
+
+ golden, golden_declared = _persisted_golden_verilog(workspace_dir)
+ if golden is None and not golden_declared:
+ golden_paths = list(origin_dir.rglob("golden_*.v")) + list(
+ origin_dir.rglob("golden_*.v.gz")
+ )
+ golden = golden_paths[0] if golden_paths else None
+
+ verilog_path = [path for path in origin_dir.rglob("*.v") if path != golden]
+ verilog_gz_path = [path for path in origin_dir.rglob("*.v.gz") if path != golden]
+ if verilog_path:
+ workspace.design.origin_verilog = verilog_path[0]
+ if verilog_gz_path:
+ workspace.design.origin_verilog = verilog_gz_path[0]
+ if golden is not None:
+ workspace.design.golden_verilog = golden
+
+ filelist_path = origin_dir / "filelist"
+ if filelist_path.exists():
+ workspace.design.input_filelist = filelist_path
+
+ workspace.flow.path = home_dir / "flow.json"
+ if read_only:
+ workspace.home.path = home_dir / "home.json"
+ home_data = json_read(workspace.home.path)
+ workspace.home.data = home_data if isinstance(home_data, dict) else {}
+ workspace.logger = Logger(name=parameters.data["design"])
+ else:
+ home_dir.mkdir(parents=True, exist_ok=True)
+ workspace.config["dir"].mkdir(parents=True, exist_ok=True)
+ workspace.home.init(path=home_dir / "home.json")
+ workspace.home.set_flow(workspace.flow.path)
+ workspace.home.set_checklist(home_dir / "checklist.json")
+ workspace.home.set_parameters(workspace.parameters.path)
+ workspace.logger = create_logger(
+ name=parameters.data["design"], log_dir=workspace_dir / "log"
+ )
+ log_workspace(workspace)
+ log_parameters(workspace)
+
+ return workspace
diff --git a/chipcompiler/tools/ecc/checklist_render.py b/chipcompiler/tools/ecc/checklist_render.py
new file mode 100644
index 00000000..03c381e6
--- /dev/null
+++ b/chipcompiler/tools/ecc/checklist_render.py
@@ -0,0 +1,13 @@
+"""Non-persisting checklist rendering for read-only package inspection."""
+
+from pathlib import Path
+
+from chipcompiler.data import Checklist
+
+
+def render_checklist(path: Path | str, items, *, persist: bool = False) -> dict:
+ if persist:
+ checklist = Checklist(path)
+ checklist.replace(list(items))
+ return checklist.data
+ return Checklist.from_items(path, items)
diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py
index 304eb23e..e69d6733 100644
--- a/chipcompiler/tools/ecc/signoff_checklist.py
+++ b/chipcompiler/tools/ecc/signoff_checklist.py
@@ -694,17 +694,6 @@ def rebuild_home_checklist(
checklist_path = workspace_dir / "home" / "checklist.json"
if workspace.home.path is not None:
workspace.home.set_checklist(checklist_path)
- if persist:
- checklist = Checklist(checklist_path)
- checklist.replace(list(deduplicated.values()))
- else:
- # Build the same normalized payload without Checklist.__init__ or
- # replace(), both of which intentionally write to home/checklist.json.
- checklist = object.__new__(Checklist)
- checklist.path = Path(checklist_path)
- checklist.data = checklist._default_data()
- checklist.data["checklist"] = [
- checklist._normalize_item(item) for item in deduplicated.values()
- ]
- Checklist._refresh_summary(checklist.data)
- return checklist.data
+ from chipcompiler.tools.ecc.checklist_render import render_checklist
+
+ return render_checklist(checklist_path, deduplicated.values(), persist=persist)
From cd718d092bfdd227602f3e30a840d68062767657 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Wed, 9 Sep 2026 05:17:19 +0800
Subject: [PATCH 19/25] fix(signoff): keep read-only inspection side-effect
free
---
chipcompiler/tools/ecc/signoff_checklist.py | 2 +-
test/engine/test_workspace_configuration.py | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/chipcompiler/tools/ecc/signoff_checklist.py b/chipcompiler/tools/ecc/signoff_checklist.py
index e69d6733..5c9ab93a 100644
--- a/chipcompiler/tools/ecc/signoff_checklist.py
+++ b/chipcompiler/tools/ecc/signoff_checklist.py
@@ -692,7 +692,7 @@ def rebuild_home_checklist(
# Recover home.json files whose checklist path was cleared by an older
# home.reset(); persist so later checklist updates resolve as well.
checklist_path = workspace_dir / "home" / "checklist.json"
- if workspace.home.path is not None:
+ if persist and workspace.home.path is not None:
workspace.home.set_checklist(checklist_path)
from chipcompiler.tools.ecc.checklist_render import render_checklist
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
index e98e3126..a4ae5a8a 100644
--- a/test/engine/test_workspace_configuration.py
+++ b/test/engine/test_workspace_configuration.py
@@ -4,6 +4,7 @@
import pytest
+from chipcompiler.data import load_workspace
from chipcompiler.engine import (
EngineFlow,
WorkspaceLifecycleError,
@@ -106,16 +107,23 @@ def test_read_workspace_configuration_does_not_materialize_missing_home_files(
bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings)
home_file = workspace.directory / "home" / "home.json"
+ checklist_file = workspace.directory / "home" / "checklist.json"
log_dir = workspace.directory / "log"
home_file.unlink()
+ checklist_file.unlink()
if log_dir.exists():
shutil.rmtree(log_dir)
shutil.rmtree(Path(bindings["pdk"]["root"]))
configuration = read_workspace_configuration_from_directory(workspace.directory)
+ readonly_workspace = load_workspace(workspace.directory, read_only=True)
+ from chipcompiler.tools.ecc.signoff_checklist import rebuild_home_checklist
+
+ rebuild_home_checklist(readonly_workspace, persist=False)
assert configuration["workspaceSpec"]["pdk"]["familyId"] == "ics55"
assert not home_file.exists()
+ assert not checklist_file.exists()
assert not log_dir.exists()
From d16dedd6acfdd308f14b1c1bf63159e7fadff9f0 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 12:24:29 +0800
Subject: [PATCH 20/25] refactor(workspace): replace in-memory restore with
flock backups
Keep configuration updates under an exclusive lock and recover from
on-disk backups instead of restoring file bytes from process memory.
---
chipcompiler/data/__init__.py | 6 +
chipcompiler/data/workspace/loader.py | 3 +
chipcompiler/data/workspace_transaction.py | 122 ++++++++++++++++++
.../engine/workspace_configuration.py | 88 +++++--------
test/engine/test_workspace_configuration.py | 81 ++++++++++++
5 files changed, 244 insertions(+), 56 deletions(-)
create mode 100644 chipcompiler/data/workspace_transaction.py
diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py
index 11b3e9b1..e435c8fb 100644
--- a/chipcompiler/data/__init__.py
+++ b/chipcompiler/data/__init__.py
@@ -70,6 +70,10 @@
YosysReport,
YosysStep,
)
+from .workspace_transaction import (
+ WorkspaceFileTransaction,
+ recover_workspace_file_transaction,
+)
__all__ = [
"create_workspace",
@@ -109,6 +113,8 @@
"build_workspace_config_paths",
"workspace_config_paths",
"workspace_config_path",
+ "WorkspaceFileTransaction",
+ "recover_workspace_file_transaction",
"step_config_keys",
"step_config_paths",
"init_workspace_config",
diff --git a/chipcompiler/data/workspace/loader.py b/chipcompiler/data/workspace/loader.py
index 46bf1d2f..e08b06e7 100644
--- a/chipcompiler/data/workspace/loader.py
+++ b/chipcompiler/data/workspace/loader.py
@@ -33,6 +33,9 @@ def load_workspace(directory: str | Path, *, read_only: bool = False) -> Any:
return None
if not read_only:
+ from ..workspace_transaction import recover_workspace_file_transaction
+
+ recover_workspace_file_transaction(workspace_dir)
migrate_legacy_parameters(workspace_dir)
workspace = Workspace()
diff --git a/chipcompiler/data/workspace_transaction.py b/chipcompiler/data/workspace_transaction.py
new file mode 100644
index 00000000..96d23b14
--- /dev/null
+++ b/chipcompiler/data/workspace_transaction.py
@@ -0,0 +1,122 @@
+import fcntl
+import os
+import shutil
+from collections.abc import Iterable
+from pathlib import Path
+from typing import BinaryIO
+
+_BACKUP_NAME = ".workspace-configuration-backup"
+_DISCARD_NAME = f"{_BACKUP_NAME}.discard"
+_ACTIVE: set[Path] = set()
+
+
+class WorkspaceFileTransaction:
+ def __init__(self, workspace: Path, lock: BinaryIO):
+ self.workspace = workspace
+ self.lock = lock
+ self.backup = workspace / "home" / _BACKUP_NAME
+ self.finished = False
+
+ @classmethod
+ def begin(cls, workspace: str | Path, paths: Iterable[Path]) -> "WorkspaceFileTransaction":
+ root = Path(workspace).expanduser().resolve()
+ lock = _open_lock(_lock_path(root))
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
+ try:
+ _recover_locked(root)
+ transaction = cls(root, lock)
+ transaction._prepare(paths)
+ _ACTIVE.add(root)
+ return transaction
+ except BaseException:
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
+ lock.close()
+ raise
+
+ def commit(self) -> None:
+ discard = self.workspace / "home" / _DISCARD_NAME
+ if discard.exists():
+ shutil.rmtree(discard)
+ if self.backup.exists():
+ self.backup.replace(discard)
+ shutil.rmtree(discard)
+ self._release()
+
+ def rollback(self) -> None:
+ try:
+ _restore_backup(self.workspace, self.backup)
+ shutil.rmtree(self.backup, ignore_errors=True)
+ finally:
+ self._release()
+
+ def _prepare(self, paths: Iterable[Path]) -> None:
+ home = self.workspace / "home"
+ if home.is_symlink():
+ raise OSError(f"Workspace home directory is a symlink: {home}")
+ home.mkdir(parents=True, exist_ok=True)
+ shutil.rmtree(self.backup, ignore_errors=True)
+ self.backup.mkdir(parents=True)
+ for path in sorted(set(paths)):
+ target = Path(path).resolve()
+ relative = target.relative_to(self.workspace)
+ if not target.is_file():
+ continue
+ destination = self.backup / relative
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(target, destination)
+
+ def _release(self) -> None:
+ if self.finished:
+ return
+ _ACTIVE.discard(self.workspace)
+ fcntl.flock(self.lock.fileno(), fcntl.LOCK_UN)
+ self.lock.close()
+ self.finished = True
+
+
+def recover_workspace_file_transaction(workspace: str | Path) -> None:
+ root = Path(workspace).expanduser().resolve()
+ if root in _ACTIVE:
+ return
+ home = root / "home"
+ if not (home / _BACKUP_NAME).exists() and not (home / _DISCARD_NAME).exists():
+ return
+ with _open_lock(_lock_path(root)) as lock:
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
+ try:
+ _recover_locked(root)
+ finally:
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
+
+
+def _recover_locked(workspace: Path) -> None:
+ home = workspace / "home"
+ if home.is_symlink():
+ raise OSError(f"Workspace home directory is a symlink: {home}")
+ discard = home / _DISCARD_NAME
+ if discard.exists():
+ shutil.rmtree(discard)
+ backup = home / _BACKUP_NAME
+ if backup.exists():
+ _restore_backup(workspace, backup)
+ shutil.rmtree(backup)
+
+
+def _restore_backup(workspace: Path, backup: Path) -> None:
+ if not backup.is_dir():
+ return
+ for source in backup.rglob("*"):
+ if not source.is_file():
+ continue
+ target = workspace / source.relative_to(backup)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, target)
+
+
+def _lock_path(workspace: Path) -> Path:
+ return workspace.parent / f".{workspace.name}.configuration.lock"
+
+
+def _open_lock(path: Path) -> BinaryIO:
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
+ return os.fdopen(descriptor, "a+b")
diff --git a/chipcompiler/engine/workspace_configuration.py b/chipcompiler/engine/workspace_configuration.py
index 7964408b..d4e1eb8f 100644
--- a/chipcompiler/engine/workspace_configuration.py
+++ b/chipcompiler/engine/workspace_configuration.py
@@ -1,5 +1,3 @@
-import os
-import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -16,6 +14,7 @@
update_workspace_param_value,
workspace_param_value,
)
+from chipcompiler.data.workspace_transaction import WorkspaceFileTransaction
from chipcompiler.engine.flow import EngineFlow
from chipcompiler.engine.rerun import invalidate_from
from chipcompiler.engine.snapshot import (
@@ -47,21 +46,16 @@ def update_workspace_configuration(
target = Path(target_directory).expanduser().resolve()
if not target.is_dir():
raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
- from chipcompiler.engine.reconcile import _workspace_lock
-
- with _workspace_lock(target):
- before = _snapshot_files(_workspace_file_paths(target))
- try:
- return _update_workspace_configuration(
- target,
- expected_workspace_revision,
- configuration,
- workspace_bindings,
- command_id,
- )
- except BaseException:
- _restore_files(before)
- raise
+ return _run_file_transaction(
+ target,
+ lambda: _update_workspace_configuration(
+ target,
+ expected_workspace_revision,
+ configuration,
+ workspace_bindings,
+ command_id,
+ ),
+ )
def _update_workspace_configuration(
@@ -247,21 +241,16 @@ def update_workspace_step_configuration(
target = Path(target_directory).expanduser().resolve()
if not target.is_dir():
raise WorkspaceLifecycleError("workspace_missing", f"Workspace not found: {target}")
- from chipcompiler.engine.reconcile import _workspace_lock
-
- with _workspace_lock(target):
- before = _snapshot_files(_workspace_file_paths(target))
- try:
- return _update_workspace_step_configuration(
- target,
- expected_workspace_revision,
- step_id,
- parameters,
- command_id,
- )
- except BaseException:
- _restore_files(before)
- raise
+ return _run_file_transaction(
+ target,
+ lambda: _update_workspace_step_configuration(
+ target,
+ expected_workspace_revision,
+ step_id,
+ parameters,
+ command_id,
+ ),
+ )
def _update_workspace_step_configuration(
@@ -461,6 +450,17 @@ def _flow_id(names: list[str]) -> str:
return "custom"
+def _run_file_transaction(target: Path, apply):
+ transaction = WorkspaceFileTransaction.begin(target, _workspace_file_paths(target))
+ try:
+ result = apply()
+ transaction.commit()
+ return result
+ except BaseException:
+ transaction.rollback()
+ raise
+
+
def _workspace_file_paths(target: Path) -> list[Path]:
paths = [
target / "home" / "params.toml",
@@ -471,27 +471,3 @@ def _workspace_file_paths(target: Path) -> list[Path]:
]
paths.extend(path for key, path in workspace_config_paths(target).items() if key != "dir")
return paths
-
-
-def _snapshot_files(paths: list[Path]) -> dict[Path, bytes | None]:
- return {path: path.read_bytes() if path.is_file() else None for path in paths}
-
-
-def _restore_files(snapshot: dict[Path, bytes | None]) -> None:
- for path, content in snapshot.items():
- if content is None:
- path.unlink(missing_ok=True)
- else:
- path.parent.mkdir(parents=True, exist_ok=True)
- fd, temporary = tempfile.mkstemp(
- dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
- )
- try:
- with os.fdopen(fd, "wb") as handle:
- handle.write(content)
- handle.flush()
- os.fsync(handle.fileno())
- os.replace(temporary, path)
- except BaseException:
- os.unlink(temporary)
- raise
diff --git a/test/engine/test_workspace_configuration.py b/test/engine/test_workspace_configuration.py
index a4ae5a8a..d1ef9a29 100644
--- a/test/engine/test_workspace_configuration.py
+++ b/test/engine/test_workspace_configuration.py
@@ -1,4 +1,5 @@
import json
+import os
from copy import deepcopy
from pathlib import Path
@@ -98,6 +99,86 @@ def fail_refresh(_workspace):
assert read_engineering_snapshot(workspace) == before_snapshot
+def test_configuration_update_recovers_after_process_exit_before_snapshot(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1")
+ before = read_engineering_snapshot(workspace)
+
+ child = os.fork()
+ if child == 0:
+ import chipcompiler.engine.workspace_configuration as configuration_module
+
+ configuration_module.invalidate_engineering_snapshot = lambda *_args, **_kwargs: os._exit(
+ 23
+ )
+ update_workspace_configuration(
+ workspace.directory,
+ before["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-crash",
+ )
+ os._exit(99)
+
+ _pid, status = os.waitpid(child, 0)
+ assert os.waitstatus_to_exitcode(status) == 23
+
+ updated = update_workspace_configuration(
+ workspace.directory,
+ before["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-crash",
+ )
+
+ assert updated.parameters.data["frequency_max"] == 250.0
+ assert read_engineering_snapshot(updated)["workspaceRevision"] == (
+ before["workspaceRevision"] + 1
+ )
+
+
+def test_configuration_update_recovers_after_process_exit_during_commit_cleanup(
+ tmp_path, minimal_ics55_pdk_factory
+):
+ spec, bindings = _workspace_spec_fixture()
+ bindings["pdk"]["root"] = str(minimal_ics55_pdk_factory(tmp_path / "pdk"))
+ workspace = create_workspace_from_spec(tmp_path / "workspace", spec, bindings, "create-1")
+ before = read_engineering_snapshot(workspace)
+
+ child = os.fork()
+ if child == 0:
+ import chipcompiler.data.workspace_transaction as transaction_module
+
+ original_rmtree = transaction_module.shutil.rmtree
+
+ def crash_cleanup(path, *args, **kwargs):
+ if Path(path).name == ".workspace-configuration-backup.discard":
+ os._exit(24)
+ return original_rmtree(path, *args, **kwargs)
+
+ transaction_module.shutil.rmtree = crash_cleanup
+ update_workspace_configuration(
+ workspace.directory,
+ before["workspaceRevision"],
+ {"parameters": {"design.frequency_mhz": 250.0}},
+ bindings,
+ "configuration-cleanup-crash",
+ )
+ os._exit(99)
+
+ _pid, status = os.waitpid(child, 0)
+ assert os.waitstatus_to_exitcode(status) == 24
+
+ recovered = load_workspace(workspace.directory)
+ assert recovered.parameters.data["frequency_max"] == 250.0
+ assert read_engineering_snapshot(recovered)["workspaceRevision"] == (
+ before["workspaceRevision"] + 1
+ )
+
+
def test_read_workspace_configuration_does_not_materialize_missing_home_files(
tmp_path, minimal_ics55_pdk_factory
):
From 13fb33fe06f1539c16f302e5340059e1d7a08a8b Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 12:24:41 +0800
Subject: [PATCH 21/25] docs(qor): point scoring rules at the shared
qor_scoring module
Move fail thresholds, weights, and metric selection into qor_scoring.
CLI reports and Snapshot assessment call that module; stop documenting
a GUI port and a second threshold table.
---
chipcompiler/cli/commands/report.py | 2 +-
chipcompiler/docs/ecc-tutorial.cn.md | 2 +-
chipcompiler/docs/ecc-tutorial.en.md | 2 +-
chipcompiler/docs/ecc-user-guide.cn.md | 2 +-
chipcompiler/docs/ecc-user-guide.en.md | 2 +-
chipcompiler/engine/qor.py | 6 +
chipcompiler/engine/qor_report.py | 245 ++++++-------------------
chipcompiler/engine/qor_scoring.py | 222 +++++++++++++++-------
docs/development.cn.md | 8 +-
docs/development.md | 27 ++-
test/engine/test_qor_scoring.py | 23 ++-
11 files changed, 263 insertions(+), 278 deletions(-)
diff --git a/chipcompiler/cli/commands/report.py b/chipcompiler/cli/commands/report.py
index 2aa6f3bf..015ad1e8 100644
--- a/chipcompiler/cli/commands/report.py
+++ b/chipcompiler/cli/commands/report.py
@@ -31,7 +31,7 @@ def _finish(subcommand: str, command_input, handler) -> None:
execute_command("report", command_input, handler, render_key=f"report:{subcommand}")
-@report_app.command("qor", help="Show the overall QoR score report (GUI scoring rules)")
+@report_app.command("qor", help="Show the overall QoR score report")
def qor_cmd(
*,
output_path: OutputPathOption = None,
diff --git a/chipcompiler/docs/ecc-tutorial.cn.md b/chipcompiler/docs/ecc-tutorial.cn.md
index dc843b35..91f33a99 100644
--- a/chipcompiler/docs/ecc-tutorial.cn.md
+++ b/chipcompiler/docs/ecc-tutorial.cn.md
@@ -517,7 +517,7 @@ $ ecc report summary
### 5.4 QoR 总分:ecc report qor
-按 GUI 项目看板同一套规则打分:每条指标折算 0–100 分,按维度加权(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1),60 分为通过线;缺项维度不重归一化(缺项会拉低总分):
+用 ECC 共用的 `qor_scoring` 规则打分(Studio Snapshot 也用这一套):每条指标折算 0–100 分,按维度加权(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1),60 分为通过线;缺项维度不重归一化(缺项会拉低总分):
```console
$ ecc report qor
diff --git a/chipcompiler/docs/ecc-tutorial.en.md b/chipcompiler/docs/ecc-tutorial.en.md
index 89f2d70e..f14138fb 100644
--- a/chipcompiler/docs/ecc-tutorial.en.md
+++ b/chipcompiler/docs/ecc-tutorial.en.md
@@ -518,7 +518,7 @@ Excerpts from this gcd run (full report: `cat` the file above):
### 5.4 QoR score: ecc report qor
-Scores the workspace with the same rules as the GUI project dashboard: each metric maps to 0–100, dimensions are weighted (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1), 60 is the pass line; absent dimensions are not renormalized (absence drags the overall score down):
+Scores the workspace with ECC's shared `qor_scoring` rules (the same table Studio Snapshot uses): each metric maps to 0–100, dimensions are weighted (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1), 60 is the pass line; absent dimensions are not renormalized (absence drags the overall score down):
```console
$ ecc report qor
diff --git a/chipcompiler/docs/ecc-user-guide.cn.md b/chipcompiler/docs/ecc-user-guide.cn.md
index c691e764..aa48f7d2 100644
--- a/chipcompiler/docs/ecc-user-guide.cn.md
+++ b/chipcompiler/docs/ecc-user-guide.cn.md
@@ -888,7 +888,7 @@ PDK / Node : ics55
### 12.2 qor — QoR 总体计分报告
-按 GUI 项目看板的计分规则给当前 workspace 打分:每条 v3 `qor_metrics.json` 指标按固定失败阈值折算 0-100 分(slack 类线性、core_utilization 目标区间 [0.45,0.70]、lower/higher_is_better 比例),维度内取平均,再按权重(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1)加权出总分——**缺项维度不重归一化**(与 GUI 一致,缺项会拉低总分);60 分为通过线。默认写 `/signoff/_qor_report.txt`:
+用 ECC 共用的 `qor_scoring` 规则给当前 workspace 打分(Studio Snapshot 也用这一套):每条 v3 `qor_metrics.json` 指标按固定失败阈值折算 0-100 分(slack 类线性、core_utilization 目标区间 [0.45,0.70]、lower/higher_is_better 比例),维度内取平均,再按权重(Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1)加权出总分——**缺项维度不重归一化**(缺项会拉低总分);60 分为通过线。默认写 `/signoff/_qor_report.txt`:
```console
$ ecc report qor --project gcd --plain
diff --git a/chipcompiler/docs/ecc-user-guide.en.md b/chipcompiler/docs/ecc-user-guide.en.md
index 398dbded..78ee4720 100644
--- a/chipcompiler/docs/ecc-user-guide.en.md
+++ b/chipcompiler/docs/ecc-user-guide.en.md
@@ -936,7 +936,7 @@ the report extracts the current state by default (the engine API
### 12.2 qor — overall QoR score report
-Scores the current workspace by the GUI project-dashboard rules: every v3 `qor_metrics.json` metric is converted to 0-100 against fixed fail thresholds (slack metrics linearly, core_utilization against the [0.45, 0.70] target window, lower/higher_is_better proportionally), averaged per dimension, then combined with the dimension weights (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1) into the overall score — **absent dimensions are not renormalized** (matching the GUI; missing dimensions lower the score); 60 is the pass line. By default written to `/signoff/_qor_report.txt`:
+Scores the current workspace with ECC's shared `qor_scoring` rules (the same table Studio Snapshot uses): every v3 `qor_metrics.json` metric is converted to 0-100 against fixed fail thresholds (slack metrics linearly, core_utilization against the [0.45, 0.70] target window, lower/higher_is_better proportionally), averaged per dimension, then combined with the dimension weights (Timing 0.35 / Power 0.25 / Routability 0.2 / Area 0.1 / Clock-DFM 0.1) into the overall score — **absent dimensions are not renormalized** (missing dimensions lower the score); 60 is the pass line. By default written to `/signoff/_qor_report.txt`:
```console
$ ecc report qor --project gcd --plain
diff --git a/chipcompiler/engine/qor.py b/chipcompiler/engine/qor.py
index 6cef28c2..c54788cb 100644
--- a/chipcompiler/engine/qor.py
+++ b/chipcompiler/engine/qor.py
@@ -1,3 +1,9 @@
+"""Assemble Snapshot `qorAssessment` from committed analysis.
+
+Scoring rules live in `qor_scoring`; this module only validates metric
+records, calls `score_qor`, and attaches gate status from step summaries.
+"""
+
import math
from typing import Any
diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py
index 2125b7b0..331b8aea 100644
--- a/chipcompiler/engine/qor_report.py
+++ b/chipcompiler/engine/qor_report.py
@@ -1,18 +1,18 @@
"""Overall QoR score report for one workspace.
-Python port of the ECOS Studio GUI scoring pipeline
-(ecos/gui/apps/renderer/src/utils/projectQorTrend.ts), restricted to the
-single-workspace view the CLI needs: normalize the per-step schema-v3
-``analysis/qor_metrics.json`` records, select project-level records, score
-each metric against the GUI fail thresholds, average per dimension, and
-combine with the GUI dimension weights (deliberately NOT renormalized over
-missing dimensions, matching GUI behavior). Trimmed relative to the GUI:
-cross-workspace trend/regression analysis, summary blocking-issue gates, and
-signoff-readiness score eligibility are project-dashboard concerns.
-
-Report source of truth: metrics files already carry dimension (category),
-polarity (direction), and the rating gate, written by
-tools/ecc/metrics.py::build_qor_metrics_payload.
+Reads current per-step ``analysis/qor_metrics.json`` files and scores them
+with ``chipcompiler.engine.qor_scoring``. That module is the only scoring
+rule table (fail thresholds, dimension weights, pass line, metric
+selection). Studio uses the same scorer via the Engineering Snapshot
+``qorAssessment``; this report does not keep a second copy of the rules.
+
+This file owns CLI collection and presentation only: metric-file
+normalization, DRC/LVS/RCX/STA flow-state gates, and the text report.
+Cross-workspace trend, summary blocking-issue gates, and signoff-readiness
+score eligibility stay on the Studio project dashboard.
+
+Metrics already carry dimension (category), polarity (direction), and the
+rating gate from tools/ecc/metrics.py::build_qor_metrics_payload.
"""
import dataclasses
@@ -21,6 +21,13 @@
from chipcompiler.data import StateEnum, StepEnum
from chipcompiler.data.step_dirs import STEP_DIRECTORIES
+from chipcompiler.engine.qor_scoring import (
+ DIMENSION_WEIGHTS,
+ QOR_SCORE_THRESHOLD,
+ QorScoringMetric,
+ score_metric,
+ score_qor,
+)
from chipcompiler.utility.json import json_read
# GUI FlowStep label for each canonical step that owns a scored directory.
@@ -50,15 +57,6 @@
FLOW_STEPS = tuple(FLOW_STEP_DIRS)
-DIMENSION_WEIGHTS = {
- "timing": 0.35,
- "power_integrity": 0.25,
- "routability_physical": 0.2,
- "area_cost": 0.1,
- "clock_robustness_dfm": 0.1,
- "runtime": 0.0,
-}
-
DIMENSION_LABELS = {
"timing": "Timing",
"power_integrity": "Power / IR / EM",
@@ -68,54 +66,9 @@
"runtime": "Runtime",
}
-METRIC_FAIL_VALUES = {
- "drc_count": 10,
- "lvs_count": 10,
- "route_wirelength": 6000,
- "route_via_count": 2000,
- "cts_buffer_count": 20,
- "cts_buffer_area": 40,
- "clock_wirelength": 400000,
- "cts_clock_wirelength_max": 100000,
- "cts_clock_tree_max_level": 20,
- "die_area": 3000,
- "core_area": 2500,
- "core_utilization": 0.85,
- "synthesis_cell_area": 3000,
- "fanout_max": 100,
- "place_hpwl": 10000,
- "place_grwl": 12000,
- "place_flute_wirelength": 10000,
- "place_congestion_egr_overflow_total": 100,
- "place_congestion_egr_overflow_max": 20,
- "place_rudy_utilization_max": 1,
- "place_lutrudy_utilization_max": 1,
- "route_dr_total_violation_count": 50,
- "route_dr_total_patch_count": 100,
- "route_dr_total_wirelength": 6000,
- "route_dr_total_via_count": 2000,
- "route_la_total_overflow": 100,
- "rcx_missing_corner_count": 9,
- "sta_setup_wns": -0.2,
- "sta_setup_tns": -1,
- "sta_hold_wns": -0.2,
- "sta_hold_tns": -1,
- "sta_frequency_mhz": 100,
- "sta_setup_violation_count": 1,
- "sta_hold_violation_count": 1,
- "sta_missing_corner_count": 1,
- "harden_artifact_missing_count": 6,
-}
-
-SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"}
-CORE_UTILIZATION_TARGET = (0.45, 0.70)
-
-#: The 0-100 line separating the GUI pass/fail presentation.
-QOR_SCORE_THRESHOLD = 60
-
GATE_STEPS = ("DRC", "LVS", "RCX", "STA")
-_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3}
+_ROLE_PRIORITY = {"final", "gate", "trend", "none"}
@dataclasses.dataclass(frozen=True)
@@ -242,90 +195,34 @@ def _normalize_metrics(step: str, payload: dict) -> list[QorMetricRecord]:
return records
-# ---------------------------------------------------------------------------
-# Scoring (port of scoreRecord / buildDimensionScores / weightedOverallScore)
-# ---------------------------------------------------------------------------
-
-
-def _clamp_score(score: float) -> float:
- return max(0.0, min(100.0, score))
-
-
-def _round_score(score: float) -> float:
- return round(score, 1)
-
-
-def _score_target_range(value: float, min_target: float, max_target: float, fail: float) -> float:
- if min_target <= value <= max_target:
- return 100.0
- if value < min_target:
- return _clamp_score(100 * value / min_target)
- return _clamp_score(100 * (fail - value) / (fail - max_target))
-
-
def score_record(record: QorMetricRecord) -> float | None:
- if record.polarity == "trend_only":
- return None
- if record.metric_name not in METRIC_FAIL_VALUES:
- return None
-
- if record.metric_name in SLACK_METRICS:
- fail = METRIC_FAIL_VALUES[record.metric_name]
- if fail >= 0:
- return None
- if record.value >= 0:
- return 100.0
- return _clamp_score(100 * (record.value - fail) / -fail)
-
- if record.polarity == "target_range":
- if record.metric_name == "core_utilization":
- return _score_target_range(
- record.value, *CORE_UTILIZATION_TARGET, METRIC_FAIL_VALUES["core_utilization"]
- )
- return None
-
- fail = METRIC_FAIL_VALUES[record.metric_name]
- if fail <= 0:
- return None
- if record.polarity == "lower_is_better":
- return _clamp_score(100 * (fail - record.value) / fail)
- return _clamp_score(100 * record.value / fail)
-
-
-def _record_key(record: QorMetricRecord) -> tuple:
- return (record.metric_name, record.scope, record.corner or "")
-
-
-def _select_project_records(records, area_scoring_step) -> list[QorMetricRecord]:
- selected: dict[tuple, QorMetricRecord] = {}
- for record in records:
- if record.project_role == "none":
- continue
- if record.dimension == "area_cost" and record.step != area_scoring_step:
- continue
- current = selected.get(_record_key(record))
- if current is None or _selection_rank(record) < _selection_rank(current):
- selected[_record_key(record)] = record
- return sorted(selected.values(), key=lambda r: r.metric_name)
-
-
-def _selection_rank(record: QorMetricRecord) -> tuple:
- try:
- step_rank = FLOW_STEPS.index(record.step)
- except ValueError:
- # Forward-version/custom steps remain presentable but have no
- # canonical ordering; prefer known flow evidence when deduplicating.
- step_rank = -1
- return (_ROLE_PRIORITY.get(record.project_role, len(_ROLE_PRIORITY)), -step_rank)
+ return score_metric(_scoring_metric(record))
+
+
+def _scoring_metric(record: QorMetricRecord) -> QorScoringMetric:
+ return QorScoringMetric(
+ step=record.step,
+ metric_id=record.metric_name,
+ value=record.value,
+ dimension=record.dimension,
+ direction=record.polarity,
+ scope=record.scope,
+ corner=record.corner,
+ project_role=record.project_role,
+ rating_score=record.rating_score,
+ payload=record,
+ )
def _resolve_area_scoring_step(records, flow_steps_by_label) -> str | None:
- for step in reversed(FLOW_STEPS):
- if flow_steps_by_label.get(step) != StateEnum.Success.value:
- continue
- if any(r.step == step and r.dimension == "area_cost" and r.rating_score for r in records):
- return step
- return None
+ scored = [
+ _scoring_metric(record)
+ for record in records
+ if record.rating_score
+ and record.dimension == "area_cost"
+ and flow_steps_by_label.get(record.step) == StateEnum.Success.value
+ ]
+ return score_qor(scored, flow_order=FLOW_STEPS).area_scoring_step
def _gate_status(flow_steps_by_label) -> str:
@@ -386,21 +283,6 @@ def _workspace_status(flow_state: str, score: float | None, gate: str) -> str:
return "Red"
-def _weighted_overall(dimension_scores: dict) -> float | None:
- weighted_total = 0.0
- used_weight = 0.0
- for dimension, score in dimension_scores.items():
- weight = DIMENSION_WEIGHTS.get(dimension, 0.0)
- if weight <= 0:
- continue
- weighted_total += score * weight
- used_weight += weight
- if used_weight == 0:
- return None
- # GUI behavior: no renormalization over missing dimensions.
- return weighted_total
-
-
# ---------------------------------------------------------------------------
# Workspace collection and rendering
# ---------------------------------------------------------------------------
@@ -452,25 +334,10 @@ def build_qor_report(workspace) -> QorScoreReport:
analyzed_steps.append(step)
records.extend(_normalize_metrics(step, payload))
- area_scoring_step = _resolve_area_scoring_step(records, flow_steps_by_label)
- project_records = _select_project_records(records, area_scoring_step)
-
- scored: list[QorMetricRecord] = []
- by_dimension: dict[str, list[float]] = {}
- for record in project_records:
- # GUI gate: only rating.score records feed dimension averages; the
- # rest stay in the table marked as trend-only.
- score = score_record(record) if record.rating_score else None
- scored.append(dataclasses.replace(record, score=score))
- if score is not None:
- by_dimension.setdefault(record.dimension, []).append(score)
-
- dimension_averages = {
- dimension: _round_score(sum(scores) / len(scores))
- for dimension, scores in by_dimension.items()
- }
- overall = _weighted_overall(dimension_averages)
- overall_score = _round_score(overall) if overall is not None else None
+ scoring = score_qor([_scoring_metric(record) for record in records], flow_order=FLOW_STEPS)
+ scored = [
+ dataclasses.replace(item.metric.payload, score=item.score) for item in scoring.metrics
+ ]
gate = _gate_status(flow_steps_by_label)
flow_state = _flow_completion_state(flow_steps_by_label.values())
@@ -490,25 +357,25 @@ def build_qor_report(workspace) -> QorScoreReport:
dimension=dimension,
label=DIMENSION_LABELS[dimension],
weight=DIMENSION_WEIGHTS[dimension],
- score=dimension_averages[dimension],
- metric_count=len(by_dimension[dimension]),
+ score=scoring.dimensions[dimension][0],
+ metric_count=scoring.dimensions[dimension][1],
)
for dimension in DIMENSION_WEIGHTS
- if dimension in dimension_averages
+ if dimension in scoring.dimensions
]
absent = [
DIMENSION_LABELS[dimension]
for dimension in DIMENSION_WEIGHTS
- if dimension not in dimension_averages and DIMENSION_WEIGHTS[dimension] > 0
+ if dimension not in scoring.dimensions and DIMENSION_WEIGHTS[dimension] > 0
]
return QorScoreReport(
workspace=str(workspace_root),
design=design,
- overall_score=overall_score,
- status=_workspace_status(flow_state, overall_score, gate),
+ overall_score=scoring.overall_score,
+ status=_workspace_status(flow_state, scoring.overall_score, gate),
gate_status=gate,
- area_scoring_step=area_scoring_step,
+ area_scoring_step=scoring.area_scoring_step,
dimension_scores=dimension_scores,
metrics=scored,
absent_dimensions=absent,
diff --git a/chipcompiler/engine/qor_scoring.py b/chipcompiler/engine/qor_scoring.py
index ce96787e..d3de78d5 100644
--- a/chipcompiler/engine/qor_scoring.py
+++ b/chipcompiler/engine/qor_scoring.py
@@ -1,19 +1,67 @@
+"""Shared QoR scoring rules.
+
+Owns fail thresholds, dimension weights, metric selection, and overall
+score. Callers: Snapshot assessment (`qor.py`) and CLI report
+(`qor_report.py`). Studio GUI consumes the Snapshot result; it does not
+keep a second rule table.
+"""
+
from dataclasses import dataclass
from typing import Any
-from .qor_report import (
- _STEP_ENUM_TO_LABEL,
- QorMetricRecord,
- _select_project_records,
- _weighted_overall,
- score_record,
-)
-from .qor_report import (
- DIMENSION_WEIGHTS as DIMENSION_WEIGHTS,
-)
-from .qor_report import (
- QOR_SCORE_THRESHOLD as QOR_SCORE_THRESHOLD,
-)
+QOR_SCORE_THRESHOLD = 60
+
+DIMENSION_WEIGHTS = {
+ "timing": 0.35,
+ "power_integrity": 0.25,
+ "routability_physical": 0.2,
+ "area_cost": 0.1,
+ "clock_robustness_dfm": 0.1,
+ "runtime": 0.0,
+}
+
+METRIC_FAIL_VALUES = {
+ "drc_count": 10,
+ "lvs_count": 10,
+ "route_wirelength": 6000,
+ "route_via_count": 2000,
+ "cts_buffer_count": 20,
+ "cts_buffer_area": 40,
+ "clock_wirelength": 400000,
+ "cts_clock_wirelength_max": 100000,
+ "cts_clock_tree_max_level": 20,
+ "die_area": 3000,
+ "core_area": 2500,
+ "core_utilization": 0.85,
+ "synthesis_cell_area": 3000,
+ "fanout_max": 100,
+ "place_hpwl": 10000,
+ "place_grwl": 12000,
+ "place_flute_wirelength": 10000,
+ "place_congestion_egr_overflow_total": 100,
+ "place_congestion_egr_overflow_max": 20,
+ "place_rudy_utilization_max": 1,
+ "place_lutrudy_utilization_max": 1,
+ "route_dr_total_violation_count": 50,
+ "route_dr_total_patch_count": 100,
+ "route_dr_total_wirelength": 6000,
+ "route_dr_total_via_count": 2000,
+ "route_la_total_overflow": 100,
+ "rcx_missing_corner_count": 9,
+ "sta_setup_wns": -0.2,
+ "sta_setup_tns": -1,
+ "sta_hold_wns": -0.2,
+ "sta_hold_tns": -1,
+ "sta_frequency_mhz": 100,
+ "sta_setup_violation_count": 1,
+ "sta_hold_violation_count": 1,
+ "sta_missing_corner_count": 1,
+ "harden_artifact_missing_count": 6,
+}
+
+_SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"}
+_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3}
+CORE_UTILIZATION_TARGET = (0.45, 0.70)
@dataclass(frozen=True)
@@ -44,75 +92,109 @@ class QorScoringResult:
overall_score: float | None
-def score_qor(records: list[QorScoringMetric]) -> QorScoringResult:
- canonical_pairs = tuple((record, _canonical_record(record)) for record in records)
- canonical = tuple(item[1] for item in canonical_pairs)
- area_step = next(
- (
- record.step
- for record in reversed(canonical)
- if record.dimension == "area_cost" and record.rating_score
- ),
- None,
- )
- selected = _select_project_records(canonical, area_step)
- original_by_canonical_id = {
- id(canonical_record): original for original, canonical_record in canonical_pairs
- }
- scored = tuple(
- ScoredQorMetric(
- original_by_canonical_id.get(id(record), _restore_record(record)),
- score_record(record) if record.rating_score else None,
+def score_qor(
+ records: list[QorScoringMetric], *, flow_order: tuple[str, ...] = ()
+) -> QorScoringResult:
+ area_step = _area_scoring_step(records, flow_order)
+ selected: dict[tuple[str, str, str], tuple[int, int, QorScoringMetric]] = {}
+ for record in records:
+ if record.project_role == "none":
+ continue
+ if record.dimension == "area_cost" and record.step != area_step:
+ continue
+ key = (record.metric_id, record.scope, record.corner or "")
+ candidate = (
+ _ROLE_PRIORITY.get(record.project_role, 3),
+ -_step_rank(record.step, flow_order),
+ record,
)
- for record in selected
+ if key not in selected or candidate[:2] < selected[key][:2]:
+ selected[key] = candidate
+
+ scored = tuple(
+ ScoredQorMetric(record, score_metric(record) if record.rating_score else None)
+ for _role, _rank, record in sorted(selected.values(), key=lambda item: item[2].metric_id)
)
by_dimension: dict[str, list[float]] = {}
for item in scored:
if item.score is not None:
by_dimension.setdefault(item.metric.dimension, []).append(item.score)
dimensions = {
- dimension: (round(sum(values) / len(values), 1), len(values))
- for dimension, values in by_dimension.items()
+ dimension: (
+ round(sum(by_dimension[dimension]) / len(by_dimension[dimension]), 1),
+ len(by_dimension[dimension]),
+ )
+ for dimension in DIMENSION_WEIGHTS
+ if dimension in by_dimension
}
- return QorScoringResult(
- area_step,
- scored,
- dimensions,
- round(_weighted_overall({key: value[0] for key, value in dimensions.items()}), 1)
- if dimensions
- else None,
+ weighted = sum(
+ score * DIMENSION_WEIGHTS[dimension]
+ for dimension, (score, _count) in dimensions.items()
+ if DIMENSION_WEIGHTS[dimension] > 0
+ )
+ overall = (
+ round(weighted, 1)
+ if any(DIMENSION_WEIGHTS[dimension] > 0 for dimension in dimensions)
+ else None
)
+ return QorScoringResult(area_step, scored, dimensions, overall)
def score_metric(record: QorScoringMetric) -> float | None:
- return score_record(_canonical_record(record))
-
-
-def _canonical_record(record: QorScoringMetric) -> QorMetricRecord:
- return QorMetricRecord(
- step=_STEP_ENUM_TO_LABEL.get(record.step, record.step),
- metric_name=record.metric_id,
- display_name=record.metric_id,
- value=record.value,
- dimension=record.dimension,
- polarity=record.direction,
- scope=record.scope,
- corner=record.corner,
- project_role=record.project_role,
- step_role="detail",
- rating_score=record.rating_score,
- )
+ if record.direction == "trend_only":
+ return None
+ fail = METRIC_FAIL_VALUES.get(record.metric_id)
+ if fail is None:
+ return None
+ if record.metric_id in _SLACK_METRICS:
+ if fail >= 0:
+ return None
+ return 100.0 if record.value >= 0 else _clamp(100 * (record.value - fail) / -fail)
+ if record.direction == "target_range":
+ if record.metric_id != "core_utilization":
+ return None
+ low, high = CORE_UTILIZATION_TARGET
+ if low <= record.value <= high:
+ return 100.0
+ if record.value < low:
+ return _clamp(100 * record.value / low)
+ return _clamp(100 * (fail - record.value) / (fail - high))
+ if fail <= 0:
+ return None
+ if record.direction == "lower_is_better":
+ return _clamp(100 * (fail - record.value) / fail)
+ if record.direction == "higher_is_better":
+ return _clamp(100 * record.value / fail)
+ return None
-def _restore_record(record: QorMetricRecord) -> QorScoringMetric:
- return QorScoringMetric(
- step=record.step,
- metric_id=record.metric_name,
- value=record.value,
- dimension=record.dimension,
- direction=record.polarity,
- scope=record.scope,
- corner=record.corner,
- project_role=record.project_role,
- rating_score=record.rating_score,
+def _area_scoring_step(records: list[QorScoringMetric], flow_order: tuple[str, ...]) -> str | None:
+ if flow_order:
+ by_step = {
+ record.step
+ for record in records
+ if record.dimension == "area_cost" and record.rating_score
+ }
+ for step in reversed(flow_order):
+ if step in by_step:
+ return step
+ return None
+ return next(
+ (
+ record.step
+ for record in reversed(records)
+ if record.dimension == "area_cost" and record.rating_score
+ ),
+ None,
)
+
+
+def _step_rank(step: str, flow_order: tuple[str, ...]) -> int:
+ try:
+ return flow_order.index(step)
+ except ValueError:
+ return -1
+
+
+def _clamp(value: float) -> float:
+ return max(0.0, min(100.0, value))
diff --git a/docs/development.cn.md b/docs/development.cn.md
index 658907aa..24c81ec4 100644
--- a/docs/development.cn.md
+++ b/docs/development.cn.md
@@ -273,7 +273,8 @@ chipcompiler/cli/inspection/ # 只读探查逻辑
chipcompiler/cli/project/ # config.py(ecc.toml 解析校验)/ config_fields.py(`ecc project` 的项目声明 schema)/ params.py(参数注册表)/ workspace_params.py(workspace 局部覆盖记录)/ manifest.py(项目形态分类)/ effective_config.py / config_params/(直配参数 schema)/ migrate*.py(旧布局迁移)/ run_*.py(run 目标解析与分发)
chipcompiler/cli/rendering/ # 输出渲染(render / renderers / pretty / progress)
chipcompiler/engine/signoff/ # 签核收集器 + 设计/checklist 报告(包,见下文)
-chipcompiler/engine/qor_report.py # QoR 总分计分(GUI 规则移植)
+chipcompiler/engine/qor_scoring.py # QoR 评分规则(阈值/权重/选指标,Studio Snapshot 与 CLI 共用)
+chipcompiler/engine/qor_report.py # CLI QoR 报告(读当前 analysis,调用 qor_scoring)
```
模块归属由 `test/cli/test_cli_module_layout.py` 强制:核心框架必须在 `cli/core/`、命令注册在 `cli/commands/`、全部处理器在唯一的 `cli/command_handlers/` 包、只读探查在 `cli/inspection/`、渲染在 `cli/rendering/`;旧的 `chipcompiler/cli/*.py` 平铺模块必须不可导入。新增文件时放进对应子包,不要在 `cli/` 根下新建模块。
@@ -427,7 +428,8 @@ config_param(
#### 扩展报告(`ecc report summary/qor/checklist/step`)
- **设计总结**:`ecc report summary` 调用 `chipcompiler.engine.signoff.generate_text_report`。其实现按职责分模块(`report.py` 编排 / `report_data.py` 数据契约 / `report_extract.py` 解析器+workspace 收集 / `report_sections.py` 分区抽取 / `report_timing.py` timing 链 / `report_text.py` 格式化),全部经包 `__init__` 对外暴露。新增报告分区时,在 `report_sections.py`(或 timing 链)增加 `_extract_(q)`,并在 `report.py` 编排处注册。
-- `engine/qor_report.py`:GUI `projectQorTrend.ts` 的单 workspace 移植——常量表(`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`)+ 归一化 + 项目级记录选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)+ `score_record` 计分公式 + 维度加权(不重归一化)。新增可计分指标 = 在 GUI 与 `METRIC_FAIL_VALUES` 同步加阈值。
+- `engine/qor_scoring.py`:唯一的 QoR 评分规则。无 I/O;负责 metric 选择(role 优先级 final>gate>trend、area_cost 只取最后成功的 area 步)、单指标阈值(`METRIC_FAIL_VALUES`)、维度平均、权重(缺项不重归一化)和 overall score。Studio 经 Snapshot `qorAssessment`(`engine/qor.py`)消费同一套规则;不要在 GUI 或 CLI 再抄一份阈值表。新增可计分指标 = 只在 `METRIC_FAIL_VALUES` 加阈值。
+- `engine/qor_report.py`:CLI `ecc report qor` 的采集与文本。读取当前 workspace 的 v3 `qor_metrics.json`,交给 `score_qor`,再渲染总分、维度表和逐指标明细。不拥有评分公式。
- `engine/signoff/report_checklist.py`:只读渲染 `home/checklist.json`(不合法时报 unavailable,绝不回写文件)。
- CLI:`cli/commands/report.py` + `cli/command_handlers/report.py`;workspace 解析复用 `inspection/discovery.py`(`resolve_workspace_path` 是无副作用核心,`resolve_command_workspace` 是核心加 `load_workspace`;signoff、report 与只读的 status/log/config 共用)。
@@ -485,7 +487,7 @@ uv run ecc run --project gcd --preset rtl2gds
### 报告
-`ecc report qor` 按 GUI 项目看板相同的方式给 workspace 打分(每指标对固定 fail 阈值计分、维度求均值、加权总分——缺失维度不做权重重归一化);`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器:
+`ecc report qor` 用共享的 `qor_scoring` 规则给 workspace 打分(每指标对固定 fail 阈值计分、维度求均值、加权总分——缺失维度不做权重重归一化);Studio Snapshot `qorAssessment` 用同一套计分器。`ecc report checklist` 渲染签核清单状态;`ecc report summary` 写出与 GUI 一致的文本设计总结。三者默认写入 `/signoff/`,接受 `-o` 以及常规的 `--project` 和可选的受管 `--workspace NAME` 选择器:
```bash
uv run ecc report qor --project gcd
diff --git a/docs/development.md b/docs/development.md
index fc48e199..6794d9c9 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -302,7 +302,8 @@ chipcompiler/cli/inspection/ # read-only probing logic
chipcompiler/cli/project/ # config.py (ecc.toml parsing and validation) / config_fields.py (project declaration schema for `ecc project`) / params.py (parameter registry) / workspace_params.py (workspace-local override records) / manifest.py (project-state classification) / effective_config.py / config_params/ (direct-config schemas) / migrate*.py (legacy-layout migration) / run_*.py (workspace target resolution and dispatch)
chipcompiler/cli/rendering/ # output rendering (render / renderers / pretty / progress)
chipcompiler/engine/signoff/ # signoff collector + design/checklist reports (package, see below)
-chipcompiler/engine/qor_report.py # overall QoR scoring (port of the GUI rules)
+chipcompiler/engine/qor_scoring.py # QoR scoring rules (thresholds/weights/selection; shared with Studio Snapshot)
+chipcompiler/engine/qor_report.py # CLI QoR report (reads current analysis, calls qor_scoring)
```
Module placement is enforced by `test/cli/test_cli_module_layout.py`: the core
@@ -570,13 +571,18 @@ required by doctor.
/ `report_text.py` formatting), all exposed through the package `__init__`.
Add a report section through an `_extract_(q)` in
`report_sections.py` (or the timing chain) and register it from `report.py`.
-- `engine/qor_report.py`: the single-workspace port of the GUI's
- `projectQorTrend.ts` — constant tables
- (`METRIC_FAIL_VALUES`/`DIMENSION_WEIGHTS`/`QOR_SCORE_THRESHOLD`) +
- normalization + project-level record selection (role priority
- final>gate>trend; area_cost only from the last successful area step) + the
- `score_record` formulas + dimension weighting (no renormalization). Adding a
- scoreable metric = adding its threshold here and in the GUI.
+- `engine/qor_scoring.py`: the only QoR scoring rules. No I/O; owns metric
+ selection (role priority final>gate>trend; area_cost only from the last
+ successful area step), per-metric thresholds (`METRIC_FAIL_VALUES`),
+ dimension averages, weights (absent dimensions are not renormalized), and
+ the overall score. Studio consumes the same rules through Snapshot
+ `qorAssessment` (`engine/qor.py`); do not copy the threshold table into the
+ GUI or CLI. Adding a scoreable metric = adding its threshold in
+ `METRIC_FAIL_VALUES` only.
+- `engine/qor_report.py`: collection and text for `ecc report qor`. Reads the
+ workspace's current v3 `qor_metrics.json` files, calls `score_qor`, then
+ renders the overall score, dimension table, and per-metric detail. It does
+ not own the scoring formulas.
- `engine/signoff/report_checklist.py`: read-only rendering of
`home/checklist.json` (reports unavailable on an invalid file; never writes
back).
@@ -665,9 +671,10 @@ uv run ecc run --project gcd --preset rtl2gds
### Reports
-`ecc report qor` scores the workspace the same way the GUI project dashboard
-does (per-metric scores against fixed fail thresholds, dimension averages,
+`ecc report qor` scores the workspace with the shared `qor_scoring` rules
+(per-metric scores against fixed fail thresholds, dimension averages,
weighted overall — weights are not renormalized over missing dimensions);
+Studio Snapshot `qorAssessment` uses the same scorer.
`ecc report checklist` renders the signoff checklist status, and `ecc report
summary` writes the GUI-parity text design summary. All three write to
`/signoff/` by default and accept `-o` plus the usual
diff --git a/test/engine/test_qor_scoring.py b/test/engine/test_qor_scoring.py
index f186b2f7..26b98410 100644
--- a/test/engine/test_qor_scoring.py
+++ b/test/engine/test_qor_scoring.py
@@ -1,4 +1,4 @@
-from chipcompiler.engine.qor_scoring import QorScoringMetric, score_qor
+from chipcompiler.engine.qor_scoring import QorScoringMetric, score_metric, score_qor
def _metric(step, metric_id, value, dimension, direction="lower_is_better", **kwargs):
@@ -43,3 +43,24 @@ def test_qor_scoring_ignores_forward_version_dimensions_and_steps():
)
assert result.dimensions == {"area_cost": (50.0, 1)}
+
+
+def test_qor_scoring_uses_flow_order_for_area_step_when_provided():
+ result = score_qor(
+ [
+ _metric("Harden", "die_area", 1500, "area_cost"),
+ _metric("DRC", "die_area", 300, "area_cost"),
+ ],
+ flow_order=("STA", "DRC", "Harden"),
+ )
+
+ assert result.area_scoring_step == "Harden"
+ assert result.dimensions == {"area_cost": (50.0, 1)}
+
+
+def test_score_metric_matches_fail_threshold_formulas():
+ slack = _metric("STA", "sta_setup_wns", -0.1, "timing", "higher_is_better")
+ assert score_metric(slack) == 50.0
+ assert score_metric(_metric("DRC", "drc_count", 0, "routability_physical")) == 100.0
+ utilization = _metric("Harden", "core_utilization", 0.55, "area_cost", "target_range")
+ assert score_metric(utilization) == 100.0
From f384034eb2aad6de29a53957fe6b8150b199e5e2 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 14:46:49 +0800
Subject: [PATCH 22/25] fix(qor): map synthesis power metrics to
power_integrity
Studio rejects the whole snapshot QoR section when any analysis metric
uses category "power". Restore the canonical power_integrity mapping
and remap existing on-disk qor_metrics.json when assembling snapshots.
---
chipcompiler/engine/analysis.py | 28 ++++++++++++++++++++++++++++
chipcompiler/tools/ecc/metrics.py | 8 ++++----
test/engine/test_qor_scoring.py | 28 ++++++++++++++++++++++++++++
test/tools/yosys/test_metrics.py | 2 +-
4 files changed, 61 insertions(+), 5 deletions(-)
diff --git a/chipcompiler/engine/analysis.py b/chipcompiler/engine/analysis.py
index 2e1c58c2..6df7763d 100644
--- a/chipcompiler/engine/analysis.py
+++ b/chipcompiler/engine/analysis.py
@@ -4,9 +4,12 @@
from typing import Any, TypeGuard
from chipcompiler.data.step_dirs import STEP_DIRECTORIES
+from chipcompiler.engine.qor_scoring import DIMENSION_WEIGHTS
from chipcompiler.tools.ecc.sta_qor import STA_POWER_REPORT_FILENAME, STA_REPORT_FILENAMES
from chipcompiler.utility import JsonReadError, file_digest, json_read_strict
+_LEGACY_METRIC_CATEGORIES = {"power": "power_integrity"}
+
_ANALYSIS_FILES = (
("metrics", "qor_metrics", "qor_metrics.json", 3),
("summary", "qor_summary", "qor_summary.json", 4),
@@ -310,9 +313,34 @@ def _analysis_file(path: Path, artifact_id: str, schema_version: int, root: Path
"reasonCode": "ANALYSIS_SCHEMA_UNSUPPORTED",
"data": None,
}
+ if schema_version == 3 and isinstance(data.get("metrics"), list):
+ data = _canonical_metrics_payload(data)
return {"artifactId": artifact_id, "status": "available", "data": data}
+def _canonical_metrics_payload(data: dict[str, Any]) -> dict[str, Any]:
+ records = data.get("metrics")
+ if not isinstance(records, list):
+ return data
+ updated = []
+ changed = False
+ for record in records:
+ if not isinstance(record, dict):
+ updated.append(record)
+ continue
+ category = record.get("category")
+ mapped = _LEGACY_METRIC_CATEGORIES.get(category, category)
+ if mapped != category and mapped in DIMENSION_WEIGHTS:
+ record = {**record, "category": mapped}
+ changed = True
+ updated.append(record)
+ if not changed:
+ return data
+ payload = dict(data)
+ payload["metrics"] = updated
+ return payload
+
+
def _subflow_summary(path: Path, root: Path) -> dict[str, Any]:
if _has_symlink(path, root):
return {"status": "unsafe", "steps": []}
diff --git a/chipcompiler/tools/ecc/metrics.py b/chipcompiler/tools/ecc/metrics.py
index fe067833..ed35d645 100644
--- a/chipcompiler/tools/ecc/metrics.py
+++ b/chipcompiler/tools/ecc/metrics.py
@@ -54,7 +54,7 @@
"name": "synthesis_power_internal_uw",
"display_name": "Synthesis Internal Power",
"unit": "uW",
- "dimension": "power",
+ "dimension": "power_integrity",
"polarity": "trend_only",
"confidence": "medium",
},
@@ -62,7 +62,7 @@
"name": "synthesis_power_switching_uw",
"display_name": "Synthesis Switching Power",
"unit": "uW",
- "dimension": "power",
+ "dimension": "power_integrity",
"polarity": "trend_only",
"confidence": "medium",
},
@@ -70,7 +70,7 @@
"name": "synthesis_power_dynamic_uw",
"display_name": "Synthesis Dynamic Power",
"unit": "uW",
- "dimension": "power",
+ "dimension": "power_integrity",
"polarity": "trend_only",
"confidence": "medium",
},
@@ -78,7 +78,7 @@
"name": "synthesis_power_leakage_uw",
"display_name": "Synthesis Leakage Power",
"unit": "uW",
- "dimension": "power",
+ "dimension": "power_integrity",
"polarity": "trend_only",
"confidence": "medium",
},
diff --git a/test/engine/test_qor_scoring.py b/test/engine/test_qor_scoring.py
index 26b98410..2f01abc2 100644
--- a/test/engine/test_qor_scoring.py
+++ b/test/engine/test_qor_scoring.py
@@ -58,6 +58,34 @@ def test_qor_scoring_uses_flow_order_for_area_step_when_provided():
assert result.dimensions == {"area_cost": (50.0, 1)}
+def test_analysis_maps_legacy_power_category_to_power_integrity(tmp_path):
+ from chipcompiler.engine.analysis import _analysis_file
+ from chipcompiler.utility import json_write
+
+ path = tmp_path / "qor_metrics.json"
+ json_write(
+ path,
+ {
+ "schema_version": 3,
+ "metrics": [
+ {
+ "id": "synthesis_power_dynamic_uw",
+ "display_name": "Synthesis Dynamic Power",
+ "value": 18.5,
+ "category": "power",
+ "direction": "trend_only",
+ "scope": "synthesis",
+ "rating": {"gate": False, "score": False, "trend": True},
+ }
+ ],
+ },
+ )
+
+ payload = _analysis_file(path, "artifact-metrics", 3, tmp_path)
+ assert payload["status"] == "available"
+ assert payload["data"]["metrics"][0]["category"] == "power_integrity"
+
+
def test_score_metric_matches_fail_threshold_formulas():
slack = _metric("STA", "sta_setup_wns", -0.1, "timing", "higher_is_better")
assert score_metric(slack) == 50.0
diff --git a/test/tools/yosys/test_metrics.py b/test/tools/yosys/test_metrics.py
index f9185a23..5e0900ef 100644
--- a/test/tools/yosys/test_metrics.py
+++ b/test/tools/yosys/test_metrics.py
@@ -98,7 +98,7 @@ def test_synthesis_metrics_write_v2_qor_files_without_legacy_metrics(tmp_path):
"display_name": "Synthesis Dynamic Power",
"value": 62.7968,
"unit": "uW",
- "category": "power",
+ "category": "power_integrity",
"direction": "trend_only",
"scope": "synthesis",
"corner": None,
From 2f75cf00c35da7a08fa45b8dd466d8da8157e8dc Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 15:16:02 +0800
Subject: [PATCH 23/25] fix(qor): restore instance metrics and dashboard detail
facts
Studio reads std_cell/macro/io-pad metrics plus database_facts and
lvs_connectivity_summary from the snapshot. Emit those from existing
feature JSON, and reject STA when pdk.sdc is unset instead of crashing.
---
chipcompiler/tools/ecc/metrics.py | 123 +++++++++++
chipcompiler/tools/ecc/qor_detail_facts.py | 210 ++++++++++++++++++
chipcompiler/tools/ecc/runner.py | 2 +-
test/tools/ecc/test_qor_detail_facts.py | 240 +++++++++++++++++++++
test/tools/ecc/test_runner.py | 44 ++++
5 files changed, 618 insertions(+), 1 deletion(-)
create mode 100644 chipcompiler/tools/ecc/qor_detail_facts.py
create mode 100644 test/tools/ecc/test_qor_detail_facts.py
diff --git a/chipcompiler/tools/ecc/metrics.py b/chipcompiler/tools/ecc/metrics.py
index ed35d645..64f3b61a 100644
--- a/chipcompiler/tools/ecc/metrics.py
+++ b/chipcompiler/tools/ecc/metrics.py
@@ -5,6 +5,7 @@
from pathlib import Path
from chipcompiler.data import EccStep, StateEnum, StepEnum, StepMetrics, Workspace, WorkspaceStep
+from chipcompiler.tools.ecc.qor_detail_facts import database_fact_summary, lvs_detail_summary
from chipcompiler.tools.ecc.sta_qor import (
POST_SYNTHESIS_STA_CORNER,
STA_POWER_SUMMARY_FILENAME,
@@ -138,6 +139,13 @@
"dimension": "area_cost",
"polarity": "trend_only",
},
+ "instance_area": {
+ "name": "instance_area",
+ "display_name": "Instance Area",
+ "unit": "um^2",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
"Total nets": {
"name": "net_count",
"display_name": "Net Count",
@@ -145,6 +153,62 @@
"dimension": "routability_physical",
"polarity": "trend_only",
},
+ "macro_count": {
+ "name": "macro_count",
+ "display_name": "Macro Count",
+ "unit": "count",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "macro_area": {
+ "name": "macro_area",
+ "display_name": "Macro Area",
+ "unit": "um^2",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "std_cell_count": {
+ "name": "std_cell_count",
+ "display_name": "Standard Cell Count",
+ "unit": "count",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "std_cell_area": {
+ "name": "std_cell_area",
+ "display_name": "Standard Cell Area",
+ "unit": "um^2",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "clock_count": {
+ "name": "clock_count",
+ "display_name": "Clock Cell Count",
+ "unit": "count",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "clock_area": {
+ "name": "clock_area",
+ "display_name": "Clock Cell Area",
+ "unit": "um^2",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
+ "io_pad_count": {
+ "name": "io_pad_count",
+ "display_name": "IO Pad Count",
+ "unit": "count",
+ "dimension": "routability_physical",
+ "polarity": "trend_only",
+ },
+ "io_pad_area": {
+ "name": "io_pad_area",
+ "display_name": "IO Pad Area",
+ "unit": "um^2",
+ "dimension": "area_cost",
+ "polarity": "trend_only",
+ },
"GP HPWL": {
"name": "place_hpwl",
"display_name": "Place HPWL",
@@ -1856,7 +1920,16 @@ def _sta_corner_context(step: WorkspaceStep, corner: str | None) -> dict | None:
"core_utilization": "/Design Layout/core_usage",
"io_pin_count": "/Design Statis/num_iopins",
"instance_count": "/Design Statis/num_instances",
+ "instance_area": "/Instances/total/area",
"net_count": "/Design Statis/num_nets",
+ "macro_count": "/Instances/macros/num",
+ "macro_area": "/Instances/macros/area",
+ "std_cell_count": "/Instances/logic/num",
+ "std_cell_area": "/Instances/logic/area",
+ "clock_count": "/Instances/clock/num",
+ "clock_area": "/Instances/clock/area",
+ "io_pad_count": "/Instances/iopads/num",
+ "io_pad_area": "/Instances/iopads/area",
}
@@ -2099,6 +2172,22 @@ def _metric_feature_source(
def _qor_detail_records(step: WorkspaceStep, step_metrics: StepMetrics) -> list[dict]:
details = []
+ database_path = getattr(step.feature, "db", None)
+ database_summary = database_fact_summary(database_path)
+ database_source = _relative_step_path(step, database_path)
+ if database_summary is not None and database_source is not None:
+ details.append(
+ {
+ "id": "database_facts",
+ "presentation": "database_facts",
+ "summary": database_summary,
+ "feature_source": {
+ "kind": "feature",
+ "path": database_source,
+ "selector": "",
+ },
+ }
+ )
detail_specs = (
("place_map_metrics", "place_map_summary", getattr(step.feature, "map", None)),
("cts_clock_skew_metrics", "cts_clock_skew_table", getattr(step.feature, "step", None)),
@@ -2165,6 +2254,23 @@ def _qor_detail_records(step: WorkspaceStep, step_metrics: StepMetrics) -> list[
},
}
)
+ if step.name == StepEnum.LVS.value:
+ feature_path = getattr(step.feature, "step", None)
+ source_path = _relative_step_path(step, feature_path)
+ summary = lvs_detail_summary(feature_path)
+ if source_path is not None and summary is not None:
+ details.append(
+ {
+ "id": "lvs_connectivity_summary",
+ "presentation": "lvs_connectivity_tables",
+ "summary": summary,
+ "feature_source": {
+ "kind": "feature",
+ "path": source_path,
+ "selector": "/lvs",
+ },
+ }
+ )
return details
@@ -3505,6 +3611,23 @@ def build_metrics_db(workspace: Workspace, step: EccStep) -> dict:
):
_add_number_metric(metrics, label, statistics.get(key))
+ instances = data.get("Instances", {})
+ instances = instances if isinstance(instances, dict) else {}
+ for metric_id, instance_kind, key in (
+ ("instance_area", "total", "area"),
+ ("macro_count", "macros", "num"),
+ ("macro_area", "macros", "area"),
+ ("std_cell_count", "logic", "num"),
+ ("std_cell_area", "logic", "area"),
+ ("clock_count", "clock", "num"),
+ ("clock_area", "clock", "area"),
+ ("io_pad_count", "iopads", "num"),
+ ("io_pad_area", "iopads", "area"),
+ ):
+ values = instances.get(instance_kind, {})
+ if isinstance(values, dict):
+ _add_number_metric(metrics, metric_id, values.get(key))
+
metrics.update(build_metrics_timing(workspace=workspace, step=step))
return metrics
diff --git a/chipcompiler/tools/ecc/qor_detail_facts.py b/chipcompiler/tools/ecc/qor_detail_facts.py
new file mode 100644
index 00000000..7777d134
--- /dev/null
+++ b/chipcompiler/tools/ecc/qor_detail_facts.py
@@ -0,0 +1,210 @@
+"""Bounded dashboard detail facts derived from step feature JSON."""
+
+from math import isfinite
+from typing import Any
+
+from chipcompiler.utility import json_read
+
+_INSTANCE_CLASS_LIMIT = 32
+_LAYER_RECORD_LIMIT = 64
+_PIN_DISTRIBUTION_LIMIT = 64
+_LVS_RECORD_LIMIT = 100
+
+
+def qor_number(value: Any) -> int | float | None:
+ if isinstance(value, bool) or value is None:
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(value) if value.is_integer() else value if isfinite(value) else None
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return None
+ percent = text.endswith("%")
+ if percent:
+ text = text[:-1].strip()
+ text = text.replace(",", "")
+ try:
+ number = float(text)
+ except ValueError:
+ return None
+ if percent:
+ number = number / 100.0
+ if not isfinite(number):
+ return None
+ return int(number) if number.is_integer() else number
+ return None
+
+
+def database_fact_summary(feature_path) -> dict | None:
+ data = json_read(feature_path or "")
+ if not isinstance(data, dict):
+ return None
+ layout = data.get("Design Layout")
+ statistics = data.get("Design Statis")
+ instances = data.get("Instances")
+ pins = data.get("Pins")
+ layers = data.get("Layers")
+ nets = data.get("Nets")
+ layout = layout if isinstance(layout, dict) else {}
+ statistics = statistics if isinstance(statistics, dict) else {}
+ instances = instances if isinstance(instances, dict) else {}
+ pins = pins if isinstance(pins, dict) else {}
+ layers = layers if isinstance(layers, dict) else {}
+ nets = nets if isinstance(nets, dict) else {}
+
+ def instance_record(kind, value):
+ value = value if isinstance(value, dict) else {}
+ return {
+ "kind": kind,
+ "count": qor_number(value.get("num")),
+ "area": qor_number(value.get("area")),
+ "pin_count": qor_number(value.get("pin_num")),
+ }
+
+ def layer_records(value, source_metric, output_metric):
+ if not isinstance(value, list):
+ return []
+ records = []
+ for index, item in enumerate(value[:_LAYER_RECORD_LIMIT]):
+ if not isinstance(item, dict):
+ continue
+ layer = item.get("layer_name")
+ records.append(
+ {
+ "layer": layer if isinstance(layer, str) and layer else f"Layer {index + 1}",
+ output_metric: qor_number(item.get(source_metric)),
+ }
+ )
+ return records
+
+ pin_distribution = pins.get("pin_distribution")
+ summary = {
+ "schema_version": 1,
+ "layout": {
+ "die_area": qor_number(layout.get("die_area")),
+ "die_usage": qor_number(layout.get("die_usage")),
+ "die_width": qor_number(layout.get("die_bounding_width")),
+ "die_height": qor_number(layout.get("die_bounding_height")),
+ "core_area": qor_number(layout.get("core_area")),
+ "core_usage": qor_number(layout.get("core_usage")),
+ "core_width": qor_number(layout.get("core_bounding_width")),
+ "core_height": qor_number(layout.get("core_bounding_height")),
+ "dbu": qor_number(layout.get("design_dbu")),
+ },
+ "statistics": {
+ "io_pins": qor_number(statistics.get("num_iopins")),
+ "instances": qor_number(statistics.get("num_instances")),
+ "nets": qor_number(statistics.get("num_nets")),
+ "pdn": qor_number(statistics.get("num_pdn")),
+ },
+ "instance_classes": [
+ instance_record(kind, value)
+ for kind, value in sorted(instances.items())
+ if kind != "total" and isinstance(kind, str) and isinstance(value, dict)
+ ][:_INSTANCE_CLASS_LIMIT],
+ "instance_total": instance_record("total", instances.get("total")),
+ "pin_distribution": [
+ {
+ "pin_count": int(pin_count),
+ "instance_count": qor_number(item.get("inst_num")),
+ "net_count": qor_number(item.get("net_num")),
+ }
+ for item in (
+ pin_distribution[:_PIN_DISTRIBUTION_LIMIT]
+ if isinstance(pin_distribution, list)
+ else []
+ )
+ if isinstance(item, dict)
+ and (pin_count := qor_number(item.get("pin_num"))) is not None
+ and pin_count >= 0
+ and float(pin_count).is_integer()
+ ],
+ "cut_layers": layer_records(layers.get("cut_layers"), "via_num", "via_count"),
+ "routing_layers": layer_records(layers.get("routing_layers"), "wire_len", "wire_length"),
+ "wire_length": qor_number(nets.get("wire_len")),
+ "via_count": qor_number(nets.get("num_via")),
+ }
+ scalar_groups = (summary["layout"], summary["statistics"])
+ has_instance_total = any(
+ summary["instance_total"][key] is not None for key in ("count", "area", "pin_count")
+ )
+ if (
+ not has_instance_total
+ and not any(value is not None for group in scalar_groups for value in group.values())
+ and not any(
+ summary[key]
+ for key in ("instance_classes", "pin_distribution", "cut_layers", "routing_layers")
+ )
+ and summary["wire_length"] is None
+ and summary["via_count"] is None
+ ):
+ return None
+ return summary
+
+
+def lvs_text(value) -> str:
+ if isinstance(value, list):
+ return ", ".join(str(item).strip() for item in value if str(item).strip())
+ return value.strip() if isinstance(value, str) else ""
+
+
+def lvs_detail_summary(feature_path) -> dict | None:
+ feature = json_read(feature_path or "")
+ if not isinstance(feature, dict):
+ return None
+ section = feature.get("lvs", feature)
+ if not isinstance(section, dict):
+ return None
+ entities = []
+ raw_entities = section.get("entity", [])
+ for item in raw_entities[:_LVS_RECORD_LIMIT] if isinstance(raw_entities, list) else []:
+ if not isinstance(item, dict) or not lvs_text(item.get("entity")):
+ continue
+ entities.append(
+ {
+ "entity": lvs_text(item.get("entity")),
+ "netlist": qor_number(item.get("netlist")),
+ "def": qor_number(item.get("def")),
+ "difference": qor_number(item.get("difference")),
+ }
+ )
+ connectivity = []
+ raw_connectivity = section.get("connectivity", [])
+ for item in raw_connectivity[:_LVS_RECORD_LIMIT] if isinstance(raw_connectivity, list) else []:
+ if not isinstance(item, dict) or not lvs_text(item.get("connectivity")):
+ continue
+ connectivity.append(
+ {
+ "connectivity": lvs_text(item.get("connectivity")),
+ "open": qor_number(item.get("open")),
+ "short": qor_number(item.get("short")),
+ "connected": qor_number(item.get("connected")),
+ "total": qor_number(item.get("total")),
+ }
+ )
+ violations = []
+ raw_violations = section.get("violations", [])
+ for item in raw_violations[:_LVS_RECORD_LIMIT] if isinstance(raw_violations, list) else []:
+ if not isinstance(item, dict):
+ continue
+ violation_type = lvs_text(item.get("type"))
+ if not violation_type:
+ continue
+ violations.append(
+ {
+ "type": violation_type,
+ "net": lvs_text(item.get("net")),
+ "instance": lvs_text(item.get("instance")),
+ "terminals": lvs_text(item.get("terminals")),
+ "components": lvs_text(item.get("components")),
+ }
+ )
+ return {
+ "schema_version": 1,
+ "entities": entities,
+ "connectivity": connectivity,
+ "violations": violations,
+ }
diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py
index 74bde795..18ef2575 100644
--- a/chipcompiler/tools/ecc/runner.py
+++ b/chipcompiler/tools/ecc/runner.py
@@ -883,7 +883,7 @@ def run_sta(workspace: Workspace, step: EccStep, ecc_module: ECCToolsModule | No
sub_flow.update_step(step_name=EccSubFlowEnum.run_sta.value, state=StateEnum.Imcomplete)
return False
- if not os.path.exists(workspace.pdk.sdc):
+ if not workspace.pdk.sdc or not os.path.exists(workspace.pdk.sdc):
workspace.logger.error("STA SDC does not exist: %s", workspace.pdk.sdc)
sub_flow.update_step(step_name=EccSubFlowEnum.run_sta.value, state=StateEnum.Imcomplete)
return False
diff --git a/test/tools/ecc/test_qor_detail_facts.py b/test/tools/ecc/test_qor_detail_facts.py
new file mode 100644
index 00000000..722ff0d8
--- /dev/null
+++ b/test/tools/ecc/test_qor_detail_facts.py
@@ -0,0 +1,240 @@
+import json
+
+from chipcompiler.data import OriginDesign, StepEnum, Workspace
+from chipcompiler.tools.ecc.builder import build_step, build_step_space
+from chipcompiler.tools.ecc.metrics import build_metrics_floorplan, build_metrics_lvs
+from chipcompiler.tools.ecc.qor_detail_facts import database_fact_summary
+
+
+def test_database_fact_summary_extracts_layout_and_instance_classes(tmp_path):
+ path = tmp_path / "Floorplan.db.json"
+ path.write_text(
+ json.dumps(
+ {
+ "Design Layout": {
+ "die_area": 2259.861,
+ "core_area": 1778.432,
+ "die_bounding_width": 47.538,
+ "die_bounding_height": 47.538,
+ "die_usage": 0.34,
+ "core_usage": 0.42,
+ },
+ "Design Statis": {
+ "num_iopins": 58,
+ "num_instances": 615,
+ "num_nets": 361,
+ },
+ "Instances": {
+ "total": {"area": 1200.0, "num": 615},
+ "clock": {"area": 20.0, "num": 10},
+ "iopads": {"area": 10.0, "num": 2},
+ "logic": {"area": 709.25, "num": 286},
+ "macros": {"area": 401.5, "num": 3},
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ summary = database_fact_summary(path)
+
+ assert summary is not None
+ assert summary["layout"]["core_area"] == 1778.432
+ assert summary["instance_total"]["count"] == 615
+ assert {item["kind"] for item in summary["instance_classes"]} == {
+ "clock",
+ "iopads",
+ "logic",
+ "macros",
+ }
+
+
+def test_floorplan_qor_metrics_include_instance_classes_and_database_facts(tmp_path):
+ workspace = Workspace(
+ directory=tmp_path,
+ design=OriginDesign(name="gcd", top_module="gcd"),
+ )
+ step = build_step(
+ workspace=workspace,
+ step_name=StepEnum.FLOORPLAN.value,
+ input_def=tmp_path / "input.def",
+ input_verilog=tmp_path / "input.v",
+ )
+ build_step_space(step)
+ assert step.feature.db is not None
+ step.feature.db.write_text(
+ json.dumps(
+ {
+ "Design Layout": {
+ "die_area": 2259.861,
+ "core_area": 1778.432,
+ "die_bounding_width": 47.538,
+ "die_bounding_height": 47.538,
+ "die_usage": 0.34,
+ "core_usage": 0.42,
+ },
+ "Design Statis": {
+ "num_iopins": 58,
+ "num_instances": 615,
+ "num_nets": 361,
+ },
+ "Instances": {
+ "total": {"area": 1200.0, "num": 615},
+ "clock": {"area": 20.0, "num": 10},
+ "iopads": {"area": 10.0, "num": 2},
+ "logic": {"area": 709.25, "num": 286},
+ "macros": {"area": 401.5, "num": 3},
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ metrics = build_metrics_floorplan(workspace, step)
+
+ assert metrics is not None
+ assert step.analysis.qor_metrics is not None
+ assert step.analysis.qor_metrics.exists()
+ qor_metrics = json.loads(step.analysis.qor_metrics.read_text(encoding="utf-8"))
+ assert qor_metrics["schema_version"] == 3
+ assert qor_metrics["tool"] == "ecc"
+ assert qor_metrics["step"] == StepEnum.FLOORPLAN.value
+ assert qor_metrics["design"] == "gcd"
+
+ records = {record["id"]: record for record in qor_metrics["metrics"]}
+ assert records["core_utilization"]["value"] == 0.42
+ assert records["core_utilization"]["direction"] == "target_range"
+ assert records["core_area"]["value"] == 1778.432
+ assert records["die_area"]["unit"] == "um^2"
+ assert {
+ metric_id: (
+ records[metric_id]["value"],
+ records[metric_id]["source"]["selector"],
+ records[metric_id]["rating"]["score"],
+ )
+ for metric_id in (
+ "macro_count",
+ "macro_area",
+ "std_cell_count",
+ "std_cell_area",
+ "clock_count",
+ "clock_area",
+ "io_pad_count",
+ "io_pad_area",
+ "instance_area",
+ )
+ } == {
+ "macro_count": (3, "/Instances/macros/num", False),
+ "macro_area": (401.5, "/Instances/macros/area", False),
+ "std_cell_count": (286, "/Instances/logic/num", False),
+ "std_cell_area": (709.25, "/Instances/logic/area", False),
+ "clock_count": (10, "/Instances/clock/num", False),
+ "clock_area": (20, "/Instances/clock/area", False),
+ "io_pad_count": (2, "/Instances/iopads/num", False),
+ "io_pad_area": (10, "/Instances/iopads/area", False),
+ "instance_area": (1200, "/Instances/total/area", False),
+ }
+ details = {detail["id"]: detail["summary"] for detail in qor_metrics["details"]}
+ assert details["database_facts"]["layout"]["core_area"] == 1778.432
+ assert details["database_facts"]["instance_total"]["count"] == 615
+ assert {item["kind"] for item in details["database_facts"]["instance_classes"]} == {
+ "clock",
+ "iopads",
+ "logic",
+ "macros",
+ }
+
+
+def test_lvs_qor_metrics_include_connectivity_summary(tmp_path):
+ workspace = Workspace(
+ directory=tmp_path,
+ design=OriginDesign(name="gcd", top_module="gcd"),
+ )
+ step = build_step(
+ workspace=workspace,
+ step_name=StepEnum.LVS.value,
+ input_def=tmp_path / "input.def",
+ input_verilog=tmp_path / "input.v",
+ )
+ build_step_space(step)
+ assert step.feature.step is not None
+ step.feature.step.write_text(
+ json.dumps(
+ {
+ "entity": [{"entity": "nets", "netlist": 10, "def": 9, "difference": 1}],
+ "connectivity": [
+ {"connectivity": "signals", "open": 1, "short": 0, "connected": 9, "total": 10}
+ ],
+ "violations": [
+ {"type": "open", "net": ["n1"], "terminals": ["A", "B"]},
+ {"type": "short", "net": "n2", "components": ["u1", "u2"]},
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ metrics = build_metrics_lvs(workspace, step)
+
+ assert metrics is not None
+ assert metrics.data["lvs_count"] == 2
+ assert step.analysis.qor_summary is not None
+ summary = json.loads(step.analysis.qor_summary.read_text(encoding="utf-8"))
+ assert summary["quality_status"] == "blocked"
+ assert summary["gates"] == [
+ {
+ "id": "qor.lvs.clean",
+ "title": "Final LVS clean",
+ "state": "failed",
+ "blocking": True,
+ "metrics": [
+ {
+ "id": "lvs_count",
+ "actual": 2,
+ "operator": "==",
+ "expected": 0,
+ "source": {
+ "kind": "feature",
+ "path": "feature/lvs.step.json",
+ "selector": "/violations",
+ },
+ }
+ ],
+ "evidence": [
+ {
+ "kind": "feature",
+ "path": "feature/lvs.step.json",
+ "selector": "/violations",
+ }
+ ],
+ }
+ ]
+ assert step.analysis.qor_metrics is not None
+ details = {
+ detail["id"]: detail
+ for detail in json.loads(step.analysis.qor_metrics.read_text(encoding="utf-8"))["details"]
+ }
+ assert details["lvs_connectivity_summary"]["summary"] == {
+ "schema_version": 1,
+ "entities": [{"entity": "nets", "netlist": 10, "def": 9, "difference": 1}],
+ "connectivity": [
+ {"connectivity": "signals", "open": 1, "short": 0, "connected": 9, "total": 10}
+ ],
+ "violations": [
+ {
+ "type": "open",
+ "net": "n1",
+ "instance": "",
+ "terminals": "A, B",
+ "components": "",
+ },
+ {
+ "type": "short",
+ "net": "n2",
+ "instance": "",
+ "terminals": "",
+ "components": "u1, u2",
+ },
+ ],
+ }
+
diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py
index 4def6b45..b28adbd8 100644
--- a/test/tools/ecc/test_runner.py
+++ b/test/tools/ecc/test_runner.py
@@ -867,6 +867,50 @@ def test_run_sta_uses_matched_report_and_feature_corner_directories(tmp_path, mo
]
+def test_run_sta_returns_false_when_sdc_is_missing(tmp_path, monkeypatch):
+ config_dir = tmp_path / "config"
+ config_dir.mkdir()
+ max_lib = tmp_path / "pdk" / "max.lib"
+ spef = tmp_path / "RCX_ecc" / "output" / "gcd_RCworst_125C.spef"
+ for path in (max_lib, spef):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("", encoding="utf-8")
+ sta_config = config_dir / "sta_ecc.json"
+ sta_config.write_text(
+ json.dumps(
+ {
+ "liberty": [{"corner": "MAX", "temperature": 125, "path": [str(max_lib)]}],
+ "signoff": [{"MAX": ["RCworst"]}],
+ }
+ ),
+ encoding="utf-8",
+ )
+ rcx_config = config_dir / "rcx_ecc.json"
+ rcx_config.write_text(
+ json.dumps({"output": str(tmp_path / "RCX_ecc" / "data")}),
+ encoding="utf-8",
+ )
+ logger = FakeLogger()
+ workspace = Workspace(
+ directory=tmp_path,
+ design=OriginDesign(name="gcd", top_module="gcd"),
+ pdk=PDK(libs=[max_lib], sdc=None),
+ config={StepEnum.STA.value: sta_config, StepEnum.RCX.value: rcx_config},
+ logger=logger,
+ )
+ step = EccStep(
+ name=StepEnum.STA.value,
+ data=EccData(steps={StepEnum.STA.value: tmp_path / "sta_ecc" / "data" / "sta"}),
+ report=EccReport(dir=tmp_path / "sta_ecc" / "report"),
+ feature=EccFeature(dir=tmp_path / "sta_ecc" / "feature"),
+ )
+ monkeypatch.setattr(ecc_runner, "EccSubFlow", FakeSubFlow)
+ monkeypatch.setattr(ecc_runner, "get_eda_instance", lambda **kwargs: FakeSynthesisStaModule())
+
+ assert ecc_runner.run_sta(workspace, step) is False
+ assert logger.errors[0][0] == "STA SDC does not exist: %s"
+
+
def test_rcx_checklist_strips_top_module_from_spef_corner(tmp_path):
checklist = EccRcxChecklist.__new__(EccRcxChecklist)
checklist.workspace = Workspace(
From cd7eac49bbfadc0ed716a4bf317daf53e27d37c5 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 17:18:12 +0800
Subject: [PATCH 24/25] fix(snapshot): resolve sizer Timing Opt directory names
Sizer stores Timing Opt as timing_optimization_sizer. Snapshot analysis
fell back to Timing optimization_sizer, so Studio showed empty QoR,
layout, reports, and subflow for a successful step.
---
chipcompiler/data/__init__.py | 2 +
chipcompiler/data/step.py | 11 +++
chipcompiler/engine/analysis.py | 5 +-
chipcompiler/tools/ecc_sizer/builder.py | 4 +-
test/data/test_step_storage_name.py | 7 ++
test/engine/test_sizer_snapshot_directory.py | 89 ++++++++++++++++++++
6 files changed, 115 insertions(+), 3 deletions(-)
create mode 100644 test/data/test_step_storage_name.py
create mode 100644 test/engine/test_sizer_snapshot_directory.py
diff --git a/chipcompiler/data/__init__.py b/chipcompiler/data/__init__.py
index e435c8fb..638b2e29 100644
--- a/chipcompiler/data/__init__.py
+++ b/chipcompiler/data/__init__.py
@@ -18,6 +18,7 @@
is_finished_step_state,
load_metrics,
save_metrics,
+ step_storage_name,
)
from .workspace import (
OriginDesign,
@@ -138,6 +139,7 @@
"get_design_parameters",
"get_pdk",
"StepEnum",
+ "step_storage_name",
"StateEnum",
"CheckState",
"StepMetrics",
diff --git a/chipcompiler/data/step.py b/chipcompiler/data/step.py
index e4bcbd4b..16986413 100644
--- a/chipcompiler/data/step.py
+++ b/chipcompiler/data/step.py
@@ -45,6 +45,17 @@ class StateEnum(Enum):
FINISHED_STEP_STATES = frozenset({StateEnum.Success.value})
+def step_storage_name(step_name: str, tool_name: str) -> str:
+ """Directory stem for ``{stem}_{tool}`` workspace step folders.
+
+ Sizer stores Timing Opt as ``timing_optimization_sizer`` instead of
+ ``Timing optimization_sizer``. Other tools keep the flow step name.
+ """
+ if tool_name.lower() == "sizer":
+ return "_".join(step_name.split()).lower()
+ return step_name
+
+
def is_finished_step_state(state: object) -> bool:
"""Whether a persisted step state counts as done for selection and skipping.
diff --git a/chipcompiler/engine/analysis.py b/chipcompiler/engine/analysis.py
index 6df7763d..06ddc03e 100644
--- a/chipcompiler/engine/analysis.py
+++ b/chipcompiler/engine/analysis.py
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import Any, TypeGuard
+from chipcompiler.data.step import step_storage_name
from chipcompiler.data.step_dirs import STEP_DIRECTORIES
from chipcompiler.engine.qor_scoring import DIMENSION_WEIGHTS
from chipcompiler.tools.ecc.sta_qor import STA_POWER_REPORT_FILENAME, STA_REPORT_FILENAMES
@@ -52,7 +53,9 @@ def build_workspace_analysis(
identity = "".join(character for character in step_id.casefold() if character.isalnum())
if identity == "fixfanout":
continue
- step_dir = root / STEP_DIRECTORIES.get(step_id, f"{step_id}_{tool_id}")
+ step_dir = root / STEP_DIRECTORIES.get(
+ step_id, f"{step_storage_name(step_id, tool_id)}_{tool_id}"
+ )
step: dict[str, Any] = {
"stepId": step_id,
"toolId": tool_id,
diff --git a/chipcompiler/tools/ecc_sizer/builder.py b/chipcompiler/tools/ecc_sizer/builder.py
index 6009ebeb..0723038a 100644
--- a/chipcompiler/tools/ecc_sizer/builder.py
+++ b/chipcompiler/tools/ecc_sizer/builder.py
@@ -2,7 +2,7 @@
import shutil
from pathlib import Path
-from chipcompiler.data import EccStep, Workspace
+from chipcompiler.data import EccStep, Workspace, step_storage_name
from chipcompiler.tools.ecc import builder as ecc_builder
from .utility import find_sizer_root
@@ -22,7 +22,7 @@ def step_shape(
Dependency-free (no rosettakit import at module scope of this module's
callers) so deferred creation and selected creation share one shape.
"""
- safe_step_name = "_".join(step_name.split()).lower()
+ safe_step_name = step_storage_name(step_name, "sizer")
step_directory = Path(workspace.directory) / f"{safe_step_name}_sizer"
if output_def is None:
output_def = step_directory / "output" / f"{workspace.design.name}_{safe_step_name}.def.gz"
diff --git a/test/data/test_step_storage_name.py b/test/data/test_step_storage_name.py
new file mode 100644
index 00000000..1785b1ad
--- /dev/null
+++ b/test/data/test_step_storage_name.py
@@ -0,0 +1,7 @@
+from chipcompiler.data import StepEnum, step_storage_name
+
+
+def test_step_storage_name_sanitizes_sizer_timing_opt():
+ assert step_storage_name(StepEnum.TIMING_OPT.value, "sizer") == "timing_optimization"
+ assert step_storage_name(StepEnum.TIMING_OPT.value, "Sizer") == "timing_optimization"
+ assert step_storage_name(StepEnum.FLOORPLAN.value, "ecc") == StepEnum.FLOORPLAN.value
diff --git a/test/engine/test_sizer_snapshot_directory.py b/test/engine/test_sizer_snapshot_directory.py
new file mode 100644
index 00000000..e52c120a
--- /dev/null
+++ b/test/engine/test_sizer_snapshot_directory.py
@@ -0,0 +1,89 @@
+import json
+from types import SimpleNamespace
+
+from chipcompiler.engine.snapshot import create_engineering_snapshot
+
+
+def test_snapshot_resolves_canonical_sizer_step_directory(tmp_path):
+ step_dir = tmp_path / "timing_optimization_sizer"
+ analysis = step_dir / "analysis"
+ output = step_dir / "output"
+ report = step_dir / "report"
+ analysis.mkdir(parents=True)
+ output.mkdir()
+ report.mkdir()
+ metric = {
+ "id": "instance_count",
+ "display_name": "Instance Count",
+ "value": 298,
+ "unit": "count",
+ "category": "area_cost",
+ "direction": "trend_only",
+ "scope": "timing_optimization",
+ "corner": None,
+ "analysis_group": "timing optimization_metrics",
+ "rating": {"gate": False, "score": False, "trend": True},
+ "project_role": "trend",
+ "step_role": "primary",
+ "confidence": "high",
+ "source": {},
+ }
+ (analysis / "qor_metrics.json").write_text(
+ json.dumps({"schema_version": 3, "metrics": [metric]}), encoding="utf-8"
+ )
+ (analysis / "qor_summary.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 4,
+ "analysis_status": "valid",
+ "quality_status": "pass",
+ "gates": [],
+ "missing_metrics": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ (analysis / "qor_hotspots.json").write_text(
+ json.dumps({"schema_version": 3, "hotspots": []}), encoding="utf-8"
+ )
+ (output / "gcd_Timing optimization.png").write_bytes(b"layout")
+ (report / "Timing optimization.db.rpt").write_text("report", encoding="utf-8")
+ (step_dir / "subflow.json").write_text(
+ json.dumps({"steps": [{"name": "run sizer", "state": "Success"}]}),
+ encoding="utf-8",
+ )
+ workspace = SimpleNamespace(
+ directory=tmp_path,
+ design=SimpleNamespace(name="gcd"),
+ flow=SimpleNamespace(
+ data={
+ "steps": [
+ {
+ "name": "Timing optimization",
+ "tool": "sizer",
+ "state": "Success",
+ }
+ ]
+ }
+ ),
+ parameters=SimpleNamespace(data={}),
+ home=SimpleNamespace(data={}),
+ )
+
+ snapshot = create_engineering_snapshot(workspace, workspace_id="engineering-a")
+
+ step = snapshot["analysis"]["steps"][0]
+ assert step["stepId"] == "Timing optimization"
+ assert step["metrics"]["data"]["metrics"] == [metric]
+ assert step["subflow"]["status"] == "available"
+ artifacts = {artifact["kind"]: artifact for artifact in snapshot["artifacts"]}
+ assert artifacts["qor_metrics"]["reference"] == (
+ "timing_optimization_sizer/analysis/qor_metrics.json"
+ )
+ assert artifacts["qor_metrics"]["availability"] == "available"
+ assert artifacts["layout_image"]["availability"] == "available"
+ assert artifacts["layout_image"]["reference"] == (
+ "timing_optimization_sizer/output/gcd_Timing optimization.png"
+ )
+ reports = [artifact for artifact in snapshot["artifacts"] if artifact["kind"] == "report_text"]
+ assert reports[0]["availability"] == "available"
From b10ba2deec5bff5cea29d040610c618bd99dad25 Mon Sep 17 00:00:00 2001
From: KoEkko <2251930460@qq.com>
Date: Thu, 10 Sep 2026 21:38:43 +0800
Subject: [PATCH 25/25] style(test): drop extra trailing newline in qor facts
fixture
---
test/tools/ecc/test_qor_detail_facts.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/test/tools/ecc/test_qor_detail_facts.py b/test/tools/ecc/test_qor_detail_facts.py
index 722ff0d8..f4fe8537 100644
--- a/test/tools/ecc/test_qor_detail_facts.py
+++ b/test/tools/ecc/test_qor_detail_facts.py
@@ -237,4 +237,3 @@ def test_lvs_qor_metrics_include_connectivity_summary(tmp_path):
},
],
}
-