From f3eb5dff54b7f1d021b771a0527e9b826682c80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Wed, 2 Sep 2026 13:51:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + backend/app/cad_agent/adapters/runtime.py | 68 +++ .../cad_agent/adapters/sqlite_repository.py | 44 +- .../cad_agent/application/action_handlers.py | 249 ++++---- .../app/cad_agent/application/capabilities.py | 11 +- .../cad_agent/application/llm_contracts.py | 98 +++- .../app/cad_agent/application/requirements.py | 444 +++++++-------- backend/app/cad_agent/application/workflow.py | 353 ++++++++++-- backend/app/cad_agent/composition.py | 5 + backend/app/cad_agent/domain/errors.py | 1 + .../cad_agent/domain/operation_contract.py | 25 +- backend/app/cad_agent/domain/state.py | 34 +- .../app/cad_agent/domain/verifier_registry.py | 32 +- backend/app/cad_agent/evals/usable_smoke.py | 164 ++++++ backend/app/cad_agent/ports.py | 1 + backend/app/main.py | 29 +- backend/app/services/agent_service.py | 5 +- backend/engine/cdsl_engine/__init__.py | 19 +- backend/tests/test_cad_agent_v3.py | 530 +++++++++++++++--- cadfs_to_cdsl/.gitignore | 3 + cadfs_to_cdsl/README.md | 16 + cadfs_to_cdsl/__init__.py | 4 + cadfs_to_cdsl/__main__.py | 4 + cadfs_to_cdsl/cli.py | 46 ++ cadfs_to_cdsl/compare.py | 29 + cadfs_to_cdsl/dataset.py | 89 +++ cadfs_to_cdsl/diagnostics.py | 16 + cadfs_to_cdsl/featurescript_lexer.py | 33 ++ cadfs_to_cdsl/featurescript_parser.py | 126 +++++ cadfs_to_cdsl/ir.py | 39 ++ cadfs_to_cdsl/lowering.py | 313 +++++++++++ cadfs_to_cdsl/pipeline.py | 158 ++++++ cadfs_to_cdsl/query_parser.py | 51 ++ cadfs_to_cdsl/rebuild.py | 19 + cadfs_to_cdsl/reports.py | 50 ++ cadfs_to_cdsl/selector_binding.py | 34 ++ cadfs_to_cdsl/tests/__init__.py | 0 cadfs_to_cdsl/tests/test_integration.py | 25 + cadfs_to_cdsl/tests/test_lowering.py | 45 ++ cadfs_to_cdsl/tests/test_parser.py | 47 ++ cadfs_to_cdsl/units.py | 38 ++ docs/README.md | 2 + docs/cdsl-format.md | 71 +++ frontend/src/app/globals.css | 9 + frontend/src/components/agent-studio.tsx | 24 +- frontend/src/lib/cad-stream.test.ts | 4 +- frontend/src/lib/cad-stream.ts | 4 +- frontend/src/lib/cad-types.ts | 8 + json_to_cdsl/README.md | 40 ++ json_to_cdsl/download_onshape_complete.py | 379 +++++++++++++ json_to_cdsl/download_onshape_samples.py | 243 ++++++++ onshape_to_cdsl/README.md | 94 ++++ onshape_to_cdsl/download_steps.py | 273 +++++++++ .../src/onshape_to_cdsl/__init__.py | 3 + .../src/onshape_to_cdsl/__main__.py | 4 + onshape_to_cdsl/src/onshape_to_cdsl/cli.py | 161 ++++++ .../src/onshape_to_cdsl/compare.py | 106 ++++ .../src/onshape_to_cdsl/convert.py | 112 ++++ .../src/onshape_to_cdsl/download.py | 167 ++++++ onshape_to_cdsl/src/onshape_to_cdsl/issues.py | 112 ++++ .../src/onshape_to_cdsl/manifests.py | 42 ++ .../src/onshape_to_cdsl/merge_scans.py | 31 + .../src/onshape_to_cdsl/onshape_api.py | 72 +++ .../src/onshape_to_cdsl/registry.py | 45 ++ onshape_to_cdsl/src/onshape_to_cdsl/scan.py | 69 +++ onshape_to_cdsl/src/onshape_to_cdsl/select.py | 40 ++ .../src/onshape_to_cdsl/sketches.py | 120 ++++ .../src/onshape_to_cdsl/source_urls.py | 66 +++ onshape_to_cdsl/src/onshape_to_cdsl/units.py | 18 + .../src/onshape_to_cdsl/validate.py | 59 ++ onshape_to_cdsl/tests/test_pipeline.py | 126 +++++ 71 files changed, 5290 insertions(+), 515 deletions(-) create mode 100644 backend/app/cad_agent/evals/usable_smoke.py create mode 100644 cadfs_to_cdsl/.gitignore create mode 100644 cadfs_to_cdsl/README.md create mode 100644 cadfs_to_cdsl/__init__.py create mode 100644 cadfs_to_cdsl/__main__.py create mode 100644 cadfs_to_cdsl/cli.py create mode 100644 cadfs_to_cdsl/compare.py create mode 100644 cadfs_to_cdsl/dataset.py create mode 100644 cadfs_to_cdsl/diagnostics.py create mode 100644 cadfs_to_cdsl/featurescript_lexer.py create mode 100644 cadfs_to_cdsl/featurescript_parser.py create mode 100644 cadfs_to_cdsl/ir.py create mode 100644 cadfs_to_cdsl/lowering.py create mode 100644 cadfs_to_cdsl/pipeline.py create mode 100644 cadfs_to_cdsl/query_parser.py create mode 100644 cadfs_to_cdsl/rebuild.py create mode 100644 cadfs_to_cdsl/reports.py create mode 100644 cadfs_to_cdsl/selector_binding.py create mode 100644 cadfs_to_cdsl/tests/__init__.py create mode 100644 cadfs_to_cdsl/tests/test_integration.py create mode 100644 cadfs_to_cdsl/tests/test_lowering.py create mode 100644 cadfs_to_cdsl/tests/test_parser.py create mode 100644 cadfs_to_cdsl/units.py create mode 100644 docs/cdsl-format.md create mode 100644 json_to_cdsl/download_onshape_complete.py create mode 100644 json_to_cdsl/download_onshape_samples.py create mode 100644 onshape_to_cdsl/README.md create mode 100644 onshape_to_cdsl/download_steps.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/__init__.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/__main__.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/cli.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/compare.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/convert.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/download.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/issues.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/manifests.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/merge_scans.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/onshape_api.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/registry.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/scan.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/select.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/sketches.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/source_urls.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/units.py create mode 100644 onshape_to_cdsl/src/onshape_to_cdsl/validate.py create mode 100644 onshape_to_cdsl/tests/test_pipeline.py diff --git a/.gitignore b/.gitignore index 271c7273..5783912f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,11 @@ build/ # Runtime data and generated local artifacts backend/data/ +backend/live-evals/ +data/cadfs-sample/ json_to_cdsl/input/ +onshape_to_cdsl/input/ +onshape_to_cdsl/data/ # Logs *.log diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 428092e3..1c4facad 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -153,6 +153,7 @@ class ProfileCadRuntime: fragment, selector_tokens=allowed_selectors, reference_tokens=list(reference_tokens), + root_xy_datum=not bool((base_cdsl or {}).get("features")), ) if errors: raise RuntimeAdapterError( @@ -262,6 +263,73 @@ class ProfileCadRuntime: except Exception as error: raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error + def rebuild_best_effort( + self, + cdsl: dict[str, Any], + output_dir: str, + task_id: str, + revision_id: str, + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Build independent feature prefixes without discarding prior geometry. + + A full CDSL document can contain several features even though the + action protocol normally appends one at a time. Engine validation is + all-or-nothing, so replay features in source order and retain each + executable feature. A failed feature is reported as a diagnostic; + it does not erase already executable geometry or prevent later, + independent features from being tried. + """ + features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else [] + accepted: list[dict[str, Any]] = [] + accepted_ids: set[str] = set() + failures: list[dict[str, Any]] = [] + latest: dict[str, Any] | None = None + for index, feature in enumerate(features): + if not isinstance(feature, dict): + failures.append({"feature_index": index, "feature_id": "", "message": "Feature must be an object."}) + continue + feature_id = str(feature.get("id") or f"feature_{index + 1:03d}") + dependencies = [str(value) for value in feature.get("depends_on") or () if isinstance(value, str)] + unavailable = [value for value in dependencies if value not in accepted_ids] + if unavailable: + failures.append({ + "feature_index": index, + "feature_id": feature_id, + "message": "Feature was skipped because an earlier dependency did not execute.", + "dependencies": unavailable, + }) + continue + candidate = self._feature_subset(cdsl, [*accepted, feature]) + try: + latest = self.rebuild(candidate, output_dir, task_id, revision_id) + except Exception as error: + failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]}) + continue + accepted.append(deepcopy(feature)) + accepted_ids.add(feature_id) + if not accepted: + if failures: + raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt")) + raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features") + # A failed later attempt may have left partial files in the stage. + # Rebuild the retained feature set once so all published artifacts are + # guaranteed to describe the same successful checkpoint. + latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id) + return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures + + @staticmethod + def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]: + document = deepcopy(cdsl) + document["features"] = deepcopy(features) + geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {} + sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else [] + sketch_ids = {str(feature.get("sketch_id") or "") for feature in features} + document["geometry"] = { + **geometry, + "sketches": [deepcopy(sketch) for sketch in sketches if isinstance(sketch, dict) and str(sketch.get("id") or "") in sketch_ids], + } + return document + def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None: if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"): raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract") diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 30d0e948..53ad9ada 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -19,6 +19,7 @@ class SqliteTaskRepository: self.database_path = database_path self.database_path.parent.mkdir(parents=True, exist_ok=True) self._lock = RLock() + self.protocol_reset = False self._initialize() @contextmanager @@ -34,11 +35,27 @@ class SqliteTaskRepository: def _initialize(self) -> None: with self._lock, self._connection() as connection: + # Protocol 3.1 intentionally has no migration path from the + # structured-only / review-loop task model. Deployment starts with + # an empty task database, as those tasks do not have immutable + # Markdown source artifacts to compile from. + existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone() + if existing is not None and "'3.1'" not in str(existing[0] or ""): + self.protocol_reset = True + connection.executescript(""" + DROP TABLE IF EXISTS outbox; + DROP TABLE IF EXISTS tool_audits; + DROP TABLE IF EXISTS usage_records; + DROP TABLE IF EXISTS invocations; + DROP TABLE IF EXISTS ledger; + DROP TABLE IF EXISTS model_capabilities; + DROP TABLE IF EXISTS tasks; + """) connection.executescript( """ CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, - protocol_version TEXT NOT NULL CHECK(protocol_version = '3.0'), + protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'), request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL, @@ -50,6 +67,9 @@ class SqliteTaskRepository: last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '', requirements_spec_path TEXT NOT NULL DEFAULT '', + requirements_document_path TEXT NOT NULL DEFAULT '', + completion_target_path TEXT NOT NULL DEFAULT '', + modeling_plan_path TEXT NOT NULL DEFAULT '', clarification_path TEXT NOT NULL DEFAULT '', requirements_contract_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -104,8 +124,8 @@ class SqliteTaskRepository: def create_task(self, task_id: str, request: str) -> TaskState: with self._lock, self._connection() as connection: connection.execute( - "INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.0', ?, ?, 0)", - (task_id, request, TaskPhase.DRAFTING_REQUIREMENTS.value), + "INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.1', ?, ?, 0)", + (task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value), ) state = self.get_state(task_id) if state is None: @@ -130,7 +150,7 @@ class SqliteTaskRepository: for item in events if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str) ] - frozen = next((item for item in reversed(events) if item.get("event") == "requirements_contract_frozen"), {}) + frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {}) verification_warnings = [ str(item) for item in frozen.get("verification_warnings") or () if str(item) ] if isinstance(frozen, dict) else [] @@ -141,12 +161,13 @@ class SqliteTaskRepository: "no_progress_limit", "candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure", "failed_author_format", "runtime_contract_invalid", + "completed_best_effort", } - ), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {} + ), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {} questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else [] issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else [] return { - "schema_version": "3.0", + "schema_version": "3.1", "task_id": state.task_id, "phase": state.phase.value, "lifecycle": self._lifecycle(state.phase), @@ -160,10 +181,13 @@ class SqliteTaskRepository: "last_error": state.last_error.value if state.last_error else "", "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", "requirements_spec_path": state.requirements_spec_path, + "requirements_document_path": state.requirements_document_path, + "completion_target_path": state.completion_target_path, + "modeling_plan_path": state.modeling_plan_path, "clarification_path": state.clarification_path, "requirements_contract_path": state.requirements_contract_path, "verification_status": ( - "completed_with_risks" if state.phase == TaskPhase.COMPLETED and verification_warnings + "completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED) else "verified" if state.phase == TaskPhase.COMPLETED else "pending" ), @@ -219,7 +243,7 @@ class SqliteTaskRepository: cursor = connection.execute( """UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?, candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?, - clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP + requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ? AND state_version = ?""", ( state.phase.value, state.version, state.active_revision, @@ -227,6 +251,7 @@ class SqliteTaskRepository: state.candidate_id, state.candidate_stage_id, int(state.repair_required), state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path, + state.requirements_document_path, state.completion_target_path, state.modeling_plan_path, state.clarification_path, state.requirements_contract_path, state.task_id, previous_version, ), @@ -377,6 +402,9 @@ class SqliteTaskRepository: last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None, retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None, requirements_spec_path=str(row["requirements_spec_path"] or ""), + requirements_document_path=str(row["requirements_document_path"] or ""), + completion_target_path=str(row["completion_target_path"] or ""), + modeling_plan_path=str(row["modeling_plan_path"] or ""), clarification_path=str(row["clarification_path"] or ""), requirements_contract_path=str(row["requirements_contract_path"] or ""), ) diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py index 47299aa3..1c80cca3 100644 --- a/backend/app/cad_agent/application/action_handlers.py +++ b/backend/app/cad_agent/application/action_handlers.py @@ -109,8 +109,8 @@ class ActionCommandHandler: for event in self.repository.ledger_events(task_id)[-16:]: if event.get("event") in { "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", - "candidate_recovered_rejected", "final_review_repair", - }: + "candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair", + } or (event.get("event") == "accepted" and event.get("repair_required")): sequence = event.get("sequence") if isinstance(sequence, int): refs.append(f"evidence_ledger_{sequence}") @@ -134,8 +134,10 @@ class ActionCommandHandler: "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected", + "candidate_operation_skipped", "runtime_precondition_rejected", "final_review_repair", + "accepted", } diagnostics: list[dict[str, Any]] = [] for event in reversed(self.repository.ledger_events(task_id)): @@ -153,6 +155,20 @@ class ActionCommandHandler: message = event.get("message") if isinstance(message, str) and message: item["message"] = message[:500] + for field in ("issues", "failed_checklist_items"): + values = event.get(field) + if isinstance(values, list): + item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)] + failures = event.get("operation_failures") + if isinstance(failures, list): + item["operation_failures"] = [ + {key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}} + for failure in failures[:8] + if isinstance(failure, dict) + ] + fragment_hash = event.get("fragment_hash") + if isinstance(fragment_hash, str) and fragment_hash: + item["fragment_hash"] = fragment_hash stage_id = event.get("stage_id") candidate = None if isinstance(stage_id, str) and stage_id: @@ -263,7 +279,11 @@ class ActionCommandHandler: state = state or self.repository.get_state(task_id) if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: return False - return any( + has_earlier_checkpoint = any( + revision != state.active_revision + for revision in self.checkpoint_tokens(task_id, state).values() + ) + return has_earlier_checkpoint and any( event.get("event") == "geometry_conclusion" and event.get("decision") == "rollback" and event.get("working_head") == state.working_head @@ -390,6 +410,7 @@ class ActionCommandHandler: fragment, selector_tokens=allowed_selector_tokens, reference_tokens=list(references), + root_xy_datum=not bool(state.active_revision), ) if errors: return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors))) @@ -452,8 +473,48 @@ class ActionCommandHandler: if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]): return Rejected(self._stale()) try: - rebuilt = self.runtime.rebuild(cdsl, stage.output_dir, task_id, candidate_id) - claim_results = self._evaluate_claims(task_id, rebuilt) + rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id) + attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)} + executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)} + if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids): + next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) + result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures} + if not self._commit_invocation(next_state, [{ + "event": "candidate_operation_skipped", + "candidate_id": candidate_id, + "stage_id": stage.stage_id, + "action_id": action.action_id, + "working_head": action.working_head, + "checkpoint_revision": state.active_revision, + "atomic_id": action.atomic_id, + "fragment_hash": fragment_hash, + "operation_failures": operation_failures, + "message": "The submitted feature did not execute; earlier executable features were retained.", + }], invocation, result): + return Rejected(self._stale()) + return Rejected(WorkflowError( + ErrorCode.CANDIDATE_BUILD_FAILED, + "The submitted feature could not execute; the previous executable checkpoint was retained.", + details={"operation_failures": operation_failures}, + )) + try: + claim_results = self._evaluate_claims(task_id, rebuilt) + except Exception as error: + failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) + result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value} + if not self._commit_invocation(failed_state, [{ + "event": "requirements_contract_execution_failed", + "candidate_id": candidate_id, + "stage_id": stage.stage_id, + "action_id": action.action_id, + "message": str(error)[:1000], + }], invocation, result): + return Rejected(self._stale()) + return Rejected(WorkflowError( + ErrorCode.REQUIREMENTS_SPEC_INVALID, + "The frozen requirements contract could not be evaluated; no CAD repair was attempted.", + details={"diagnostic": str(error)[:1000]}, + )) operation_results = self._operation_candidate_results( action, contract, @@ -466,47 +527,11 @@ class ActionCommandHandler: *self._candidate_blockers(task_id, action.requirement_ids, claim_results), *[item for item in operation_results if item.get("status") != "pass"], ] - candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]} + candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]} self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - if blockers: - failure_class_fingerprint = self._failure_class_fingerprint( - state.active_revision, action.atomic_id, ErrorCode.CLAIM_VERIFICATION_FAILED, - ) - prior_failures = self._failure_class_events(task_id, failure_class_fingerprint) - if len(prior_failures) >= 2: - failed_state = transition(building, "failed", error=ErrorCode.NO_PROGRESS_LIMIT) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value} - if not self._commit_invocation(failed_state, [{ - "event": "no_progress_limit", "candidate_id": candidate_id, "stage_id": stage.stage_id, - "action_id": action.action_id, "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, - "failure_class_fingerprint": failure_class_fingerprint, - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same deterministic candidate failure made no progress after three attempts.")) - failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED) - result = {"candidate_id": candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results} - if not self._commit_invocation(failed_state, [{ - "event": "candidate_rejected", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, - "failure_exact_fingerprint": exact_fingerprint, - "failure_class_fingerprint": failure_class_fingerprint, - "attempt": len(prior_failures) + 1, - "reason": "deterministic_claim_failed", - "claim_results": claim_results, - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "Candidate violates a deterministic claim required by this action or a global invariant.", details={"claim_results": blockers})) review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results} - if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result): + result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures} + if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result): return Rejected(self._stale()) return Accepted(result) except OSError as error: @@ -592,7 +617,9 @@ class ActionCommandHandler: selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) references = self.runtime.reference_tokens(base) cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids)) - rebuilt = self.runtime.rebuild(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id) + rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id) + if "operation_failures" not in locals(): + operation_failures = [] claim_results = self._evaluate_claims(task_id, rebuilt) audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {} contract = self.runtime.operation_contract(action.atomic_id) @@ -611,6 +638,7 @@ class ActionCommandHandler: "fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}), "selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results, "operation_verifier_results": operation_results, + "operation_failures": operation_failures, "blockers": [ *self._candidate_blockers(task_id, action.requirement_ids, claim_results), *[item for item in operation_results if item.get("status") != "pass"], @@ -699,51 +727,9 @@ class ActionCommandHandler: event="candidate_review_storage_failure", message=str(error), ) - if review.verdict != "accept" or deterministic_fail: - if published_revision: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "A published candidate cannot be rejected during recovery.", retryable=True)) - failure_class_fingerprint = self._failure_class_fingerprint( - state.active_revision, action.atomic_id, ErrorCode.CANDIDATE_REVIEW_REJECTED, - ) - reviewed_fragment_hash = str(candidate.get("fragment_hash") or "") - failure_exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": reviewed_fragment_hash, - }) if reviewed_fragment_hash else "" - prior_failures = self._failure_class_events(task_id, failure_class_fingerprint) - if len(prior_failures) >= 2: - next_state = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT) - result = {"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value} - if not self._commit_invocation(next_state, [{ - "event": "no_progress_limit", "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, "action_id": action.action_id, - "checkpoint_revision": state.active_revision, "atomic_id": action.atomic_id, - "normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value, - "failure_class_fingerprint": failure_class_fingerprint, - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same candidate-review failure made no progress after three attempts.")) - next_state = transition(state, "candidate_rejected", error=ErrorCode.CANDIDATE_REVIEW_REJECTED, repair_required=True) - result = {"candidate_id": state.candidate_id, "status": "rejected"} - if not self._commit_invocation(next_state, [{ - "event": "candidate_rejected", - "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "review_path": "candidate-review.json", - "deterministic_fail": deterministic_fail, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": reviewed_fragment_hash, - "failure_exact_fingerprint": failure_exact_fingerprint, - "normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value, - "failure_class_fingerprint": failure_class_fingerprint, - "attempt": len(prior_failures) + 1, - }], invocation, result): - return Rejected(self._stale()) - return Accepted(result) + operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else [] + candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else [] + needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers) revision_id = self._next_revision(task_id) if published_revision and published_revision != revision_id: return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True)) @@ -755,9 +741,9 @@ class ActionCommandHandler: event="candidate_publish_storage_failure", message=str(error), ) - next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=False) - event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual} - result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted"} + next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None) + event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair} + result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"} if not self._commit_invocation(next_state, [event], invocation, result): return Rejected(self._stale()) return Accepted(result) @@ -833,21 +819,6 @@ class ActionCommandHandler: invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None if invocation is not None and invocation.status == "finished" and invocation.result is not None: return Accepted(invocation.result) - if blockers: - failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED) - result = {"candidate_id": state.candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results} - event = { - "event": "candidate_recovered_rejected", - "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "claim_results": claim_results, - } - committed = self._commit_invocation(failed, [event], invocation, result) if invocation is not None else self.repository.compare_and_swap(failed, events=[event]) - if not committed: - return Rejected(self._stale()) - return Accepted(result) review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id) result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results} committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}]) @@ -855,6 +826,43 @@ class ActionCommandHandler: return Rejected(self._stale()) return Accepted(result) + def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected: + """Publish the last executable checkpoint when further repair is bounded. + + This is deliberately separate from strict final validation. It does + not claim that unmet acceptance targets passed; it makes the usable + model and its measured gaps durable instead of converting a planning + dead-end into a failed task with no deliverable. + """ + state = self.repository.get_state(task_id) + if state is None or not state.active_revision: + return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint.")) + if state.phase == TaskPhase.COMPLETED: + return Accepted({"status": "completed", "revision_id": state.active_revision}) + key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value}) + invocation = self.repository.begin_invocation(task_id, invocation_id, key) + if invocation.status == "finished" and invocation.result is not None: + return Accepted(invocation.result) + claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) + issues = [ + f"{item.get('claim_kind')}: {item.get('status')}" + for item in claim_results + if item.get("status") != "pass" + ] + next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) + result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues} + if not self._commit_invocation(next_state, [{ + "event": "completed_best_effort", + "revision_id": state.active_revision, + "termination_code": reason.value, + "message": "Further CAD repair was bounded; the last executable checkpoint was published.", + "issues": issues, + "claim_results": claim_results, + "completion_result_path": "completion-result.md", + }], invocation, result): + return Rejected(self._stale()) + return Accepted(result) + def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected: state = self.repository.get_state(task_id) if state is None or state.phase != TaskPhase.FINAL_VALIDATION: @@ -901,6 +909,19 @@ class ActionCommandHandler: message=str(error), ) if review.verdict != "pass" or deterministic_fail or visual_not_passed: + accepted_fragment = next(( + event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id)) + if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision + ), "") + atomic_id = next(( + str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id)) + if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision + ), "") + exact_fingerprint = canonical_hash({ + "active_revision": state.active_revision, + "atomic_id": atomic_id, + "fragment_hash": accepted_fragment, + }) if accepted_fragment and atomic_id else "" next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED) result = {"status": "repair", "revision_id": state.active_revision} if not self._commit_invocation(next_state, [{ @@ -909,6 +930,18 @@ class ActionCommandHandler: "claim_results": claim_results, "review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage], "visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed], + "issues": list(review.issues), + "evidence": list(review.evidence), + "failed_checklist_items": [ + str(requirement.get("statement") or "") + for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or () + if isinstance(requirement, dict) + and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) + ], + "atomic_id": atomic_id, + "fragment_hash": accepted_fragment, + "failure_exact_fingerprint": exact_fingerprint, + "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value, }], invocation, result): return Rejected(self._stale()) return Accepted(result) diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index 25bd7906..e17328b2 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -9,12 +9,12 @@ from typing import Any, Literal from app.cad_agent.application.llm_contracts import ( EmptyCommand, ImageObservation, - RequirementsAuthorOutput, + MarkdownDocument, StatelessCandidateReview, StatelessGeometryConclusion, StatelessRollbackCheckpoint, StatelessTopologyRequest, - requirements_spec_schema, + compiled_requirements_schema, stateless_final_review_schema, stateless_next_action_schema, ) @@ -37,7 +37,10 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict if not atomic_ids: raise RuntimeError("Runtime has no operations for conformance") tools = [ - _tool("submit_requirements_spec", requirements_spec_schema(default_registry().expected_one_of_schema())), + _tool("write_requirements_document", MarkdownDocument.model_json_schema()), + _tool("write_completion_target", MarkdownDocument.model_json_schema()), + _tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)), + _tool("write_modeling_plan", MarkdownDocument.model_json_schema()), _tool("propose_next_action", stateless_next_action_schema(atomic_ids)), _tool("inspect_topology", StatelessTopologyRequest.model_json_schema()), _tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()), @@ -54,7 +57,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str: - payload = {"protocol": "cad.v3.spec.v1", "role": role, "tools": tools} + payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools} return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() diff --git a/backend/app/cad_agent/application/llm_contracts.py b/backend/app/cad_agent/application/llm_contracts.py index 712f7344..b19b727d 100644 --- a/backend/app/cad_agent/application/llm_contracts.py +++ b/backend/app/cad_agent/application/llm_contracts.py @@ -37,6 +37,28 @@ class SpecRequirementInput(StrictDto): acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) +class MarkdownDocument(StrictDto): + """A frozen human-readable design artifact, never an executable payload.""" + markdown: Annotated[str, Field(min_length=1, max_length=16_000)] + + +class CompiledRequirementInput(StrictDto): + """One verifier bundle for one server-parsed checklist item. + + The checklist text, ordering, source bindings, and all identifiers are + intentionally absent: the service owns them after Markdown is frozen. + """ + assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) + acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) + + +class CompiledRequirementsSpec(StrictDto): + requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64) + + +# Kept only so an interrupted process with an already imported old tool schema +# fails at the workflow boundary instead of failing module import. New v3.1 +# tasks never expose or accept this aggregate specification. class RequirementsSpec(StrictDto): outcome: Literal["ready"] summary: Annotated[str, Field(min_length=1, max_length=2000)] @@ -61,7 +83,9 @@ class EmptyCommand(StrictDto): class NextAction(StrictDto): working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] intent: ShortText - requirement_ids: list[Identifier] = Field(min_length=1, max_length=5) + # The server binds this list from every frozen checklist target. It is not + # author input, so a five-item UI-era limit must not reject a valid task. + requirement_ids: list[Identifier] = Field(min_length=1, max_length=64) atomic_id: Identifier expected_change: ShortText @@ -194,6 +218,66 @@ def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]: return schema +def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]: + schema = CompiledRequirementsSpec.model_json_schema() + definitions = schema.get("$defs", {}) + requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None + if isinstance(requirement, dict): + claims = requirement.get("properties", {}).get("acceptance_claims") + if isinstance(claims, dict): + claims["items"] = deepcopy(claim_one_of) + requirements = schema.get("properties", {}).get("requirements") + if isinstance(requirements, dict): + requirements["minItems"] = target_count + requirements["maxItems"] = target_count + return schema + + +def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError: + """Drop harmless compiler chatter before strict requirements validation. + + ``compile_requirements_spec`` is a compiler stage: the service only needs + the ordered verifier bundles for the frozen checklist items. Real models + sometimes add explanatory fields such as a top-level ``assumptions`` or + per-item ``statement`` even when the dynamic tool schema forbids them. Those + fields are not executable and are not part of the frozen contract, so they + should not abort a task before modeling starts. + + The verifier ``expected`` payload is intentionally not sanitized here. It + remains governed by the registry's strict per-claim schema because those + values drive deterministic validation. + """ + value = canonical_json_object(raw_arguments_json) + if isinstance(value, WorkflowError): + return value + requirements = value.get("requirements") + sanitized: dict[str, Any] = {} + if isinstance(requirements, list): + sanitized_requirements: list[Any] = [] + for requirement in requirements: + if not isinstance(requirement, dict): + sanitized_requirements.append(requirement) + continue + item: dict[str, Any] = {} + if "assumptions" in requirement: + item["assumptions"] = requirement["assumptions"] + if "acceptance_claims" in requirement: + claims = requirement["acceptance_claims"] + if isinstance(claims, list): + item["acceptance_claims"] = [ + {key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim} + if isinstance(claim, dict) else claim + for claim in claims + ] + else: + item["acceptance_claims"] = claims + sanitized_requirements.append(item) + sanitized["requirements"] = sanitized_requirements + else: + sanitized["requirements"] = requirements + return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) + + def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]: schema = StatelessNextAction.model_json_schema() properties = schema.get("properties", {}) @@ -228,18 +312,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]: return schema -def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]: - """Bind a diagnostic conclusion to evidence generated for this head.""" - schema = GeometryConclusion.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - evidence = properties.get("evidence_refs") - if isinstance(evidence, dict): - evidence["items"] = {"enum": evidence_refs} - return schema - - def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]: """Bind a rollback request to immutable checkpoints in the active lineage.""" schema = RollbackCheckpoint.model_json_schema() diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py index 3f6ab24d..b8e9b590 100644 --- a/backend/app/cad_agent/application/requirements.py +++ b/backend/app/cad_agent/application/requirements.py @@ -1,26 +1,32 @@ -"""One-pass requirements specification and server-owned contract artifacts.""" +"""Immutable Markdown-first requirements artifacts and compiled contracts.""" from __future__ import annotations from copy import deepcopy from hashlib import sha256 import json -from typing import Any +import re +from typing import Any, Callable -from app.cad_agent.application.llm_contracts import ( - RequirementsAuthorOutput, - RequirementsClarification, - RequirementsSpec, - requirements_spec_schema, -) -from app.cad_agent.application.results import Accepted, Rejected, Waiting +from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema +from app.cad_agent.application.results import Accepted, Rejected from app.cad_agent.domain.errors import ErrorCode, WorkflowError from app.cad_agent.domain.state import TaskPhase, TaskState, transition from app.cad_agent.domain.verifier_registry import VerifierRegistry from app.cad_agent.ports import ArtifactStore, TaskRepository +_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$") +_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"}) + + class RequirementsCommandHandler: + """Persist frozen documents and compile their checklist into a contract. + + The model never names targets or internal objects during compilation. The + service derives those values strictly from the immutable checklist. + """ + def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None: self.repository = repository self.artifacts = artifacts @@ -28,283 +34,257 @@ class RequirementsCommandHandler: self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {} self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {} - def register_evaluation_contract_oracle( - self, - task_id: str, - required_claims: list[dict[str, Any]], - *, - validation_capability_gaps: list[dict[str, Any]] | None = None, - ) -> None: - """Retain release-evaluation metadata without changing production decisions.""" + def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None: self._evaluation_contract_oracles[task_id] = deepcopy(required_claims) self._evaluation_capability_gaps[task_id] = [ {"id": str(item.get("id") or ""), "description": str(item.get("description") or "")} - for item in validation_capability_gaps or () - if isinstance(item, dict) + for item in validation_capability_gaps or () if isinstance(item, dict) ] def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None: claims = self._evaluation_contract_oracles.get(task_id) - if claims is None: - return None - return { + return None if claims is None else { "evaluation_only": True, "required_claims": deepcopy(claims), "known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])), } - def spec_schema(self) -> dict[str, Any]: - return requirements_spec_schema(self.registry.expected_one_of_schema()) + @staticmethod + def document_schema() -> dict[str, Any]: + return MarkdownDocument.model_json_schema() - def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting: + def compiler_schema(self, task_id: str) -> dict[str, Any]: + return compiled_requirements_schema( + self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS), + len(self._checklist_items(task_id)), + ) + + def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected: + return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document) + + def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected: + return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target) + + def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected: + return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan) + + def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected: replay = self._replay(task_id, invocation_id) if replay is not None: return replay state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase.")) - value = output.root - if isinstance(value, RequirementsClarification): - return self._record_clarification(task_id, state, value, invocation_id=invocation_id) - if not isinstance(value, RequirementsSpec): - return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification.")) - - field_errors: list[dict[str, str]] = [] - for requirement_index, requirement in enumerate(value.requirements): - for claim_index, claim in enumerate(requirement.acceptance_claims): - try: - errors = self.registry.validate_expected(claim.claim_kind, claim.expected) - except ValueError: - errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] - field_errors.extend({ - "path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}", - "message": error["message"], - } for error in errors) + if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS: + return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase.")) + targets = self._checklist_items(task_id) + if len(output.requirements) != len(targets): + return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},))) + normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets) + field_errors = self._claim_errors(normalized_output) if field_errors: - return Rejected(WorkflowError( - ErrorCode.REQUIREMENTS_SPEC_INVALID, - "Requirements specification contains an unreadable or non-executable acceptance target.", - field_errors=tuple(field_errors), - )) - - invocation = self.repository.begin_invocation( - task_id, - invocation_id, - self._key(task_id, "requirements_spec", state.working_head, value.model_dump(mode="json")), - ) + return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors))) + invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json"))) if invocation.status == "finished" and invocation.result is not None: return self._restore(invocation.result) - source_ids = list(self.artifacts.read_source_index(task_id)) - image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} - warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)] + observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} + warnings = [ + *[str(value) for value in observation.get("uncertainties") or () if str(value)], + *compiler_warnings, + ] requirements: list[dict[str, Any]] = [] claim_position = 1 - for position, item in enumerate(value.requirements, 1): + for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1): claims: list[dict[str, Any]] = [] - for claim in item.acceptance_claims: - deterministic = self.registry.definition(claim.claim_kind).deterministic - claims.append({ - "claim_id": f"claim_{claim_position:03d}", - "claim_kind": claim.claim_kind, - "expected": claim.expected, - "verification_mode": "deterministic" if deterministic else "visual", - }) + for claim in compiled.acceptance_claims: + definition = self.registry.definition(claim.claim_kind) + claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"}) claim_position += 1 - requirements.append({ - "requirement_id": f"req_{position:03d}", - "source_ids": source_ids, - "statement": item.statement, - "assumptions": list(item.assumptions), - "acceptance_claims": claims, - }) - spec_payload = { - "schema_version": "cad.requirements-spec.v1", - "summary": value.summary, - "assumptions": list(value.assumptions), - "requirements": [item.model_dump(mode="json") for item in value.requirements], - "image_observation_path": "documents/image-observation.json" if image_observation else "", - } - contract = { - "schema_version": "cad.requirements-contract.v3", - "task_id": task_id, - "summary": value.summary, - "assumptions": list(value.assumptions), - "requirements": requirements, - "verification_warnings": warnings, - } - contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims}) + spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]} + contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings} + contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest() try: - spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload) + spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec) contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id) except OSError as error: return self._park_for_storage_retry(state, str(error)) - next_state = transition( - state, - "requirements_approved", - requirements_spec_path=spec_path, - requirements_contract_path=contract_path, - clarification_path="", - ) - result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path}) - if not self._commit(next_state, [{ - "event": "requirements_contract_frozen", - "invocation_id": invocation_id, - "contract_hash": contract["contract_hash"], - "contract_path": contract_path, - "requirement_count": len(requirements), - "verification_warnings": warnings, - }], invocation, result): + next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path) + result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)}) + if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result): return Rejected(self._stale()) - self.ensure_rendered_contract_views(task_id, next_state) return result - def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None: - if not state.requirements_contract_path: - return - contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - if not isinstance(contract, dict): - raise RuntimeError("Committed requirements contract is unavailable") - self.artifacts.write_requirements_contract(task_id, contract) - self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract)) - target = self._completion_target_markdown(contract) - self.artifacts.write_text_once(task_id, "completion-target.md", target) - - def write_completion_result( - self, - task_id: str, - state: TaskState, - *, - claim_results: list[dict[str, Any]], - review: dict[str, Any], - ) -> str: + def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str: contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)} visual = iter(review.get("visual_claims") or ()) - rows = ["# Completion Result", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""] + rows = ["# Completion Result", "", "## Checklist", ""] for requirement in contract.get("requirements") or (): if not isinstance(requirement, dict): continue - rows.append(f"## {requirement.get('statement')}") + statuses: list[str] = [] + evidence: list[str] = [] for claim in requirement.get("acceptance_claims") or (): if not isinstance(claim, dict): continue - if claim.get("verification_mode") == "visual": - decision = next(visual, {}) - status = str(decision.get("status") or "unknown") - evidence = str(decision.get("evidence") or "") - else: - result = by_id.get(str(claim.get("claim_id") or ""), {}) - status = str(result.get("status") or "unknown") - evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True) - rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}") - if evidence: - rows.append(f" - Evidence: {evidence}") - rows.append("") - warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)] - if warnings: - rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""]) + result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {}) + statuses.append(str(result.get("status") or "unknown")) + value = result.get("evidence") + if value: + evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)) + rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}") + rows.extend(f" - Evidence: {value}" for value in evidence) return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n") - def _record_clarification( - self, - task_id: str, - state: TaskState, - clarification: RequirementsClarification, - *, - invocation_id: str, - ) -> Waiting | Rejected: - evidence = self.artifacts.read_source_requirements(task_id) - observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} - evidence += "\n" + json.dumps(observation, ensure_ascii=False) - missing = [quote for quote in clarification.source_quotes if quote not in evidence] - if missing: - return Rejected(WorkflowError( - ErrorCode.REQUIREMENTS_SPEC_INVALID, - "Clarification quotes must be copied from the user request or image observation.", - field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing), - )) - invocation = self.repository.begin_invocation( - task_id, - invocation_id, - self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")), - ) - payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")} + def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected: + replay = self._replay(task_id, invocation_id) + if replay is not None: + return replay + state = self.repository.get_state(task_id) + if state is None or state.phase != phase: + return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase.")) + errors = validator(document.markdown) + if errors: + return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors))) + invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json"))) + if invocation.status == "finished" and invocation.result is not None: + return self._restore(invocation.result) try: - path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload) + written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n") except OSError as error: return self._park_for_storage_retry(state, str(error)) - next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path) - result = Waiting(WorkflowError( - ErrorCode.WAITING_FOR_USER, - clarification.question, - details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)}, - )) - if not self._commit(next_state, [{ - "event": "requirements_waiting_for_user", - "invocation_id": invocation_id, - "review_path": path, - "message": clarification.question, - "questions": [clarification.question], - "source_quotes": list(clarification.source_quotes), - }], invocation, result): + kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written} + next_state = transition(state, event, **kwargs) + result = Accepted({"phase": next_state.phase.value, "path": written}) + if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result): return Rejected(self._stale()) return result - def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None: - invocation = self.repository.get_invocation(task_id, invocation_id) - if invocation is None or invocation.status != "finished" or invocation.result is None: - return None - return self._restore(invocation.result) + def _checklist_items(self, task_id: str) -> list[str]: + path = self.artifacts.task_dir(task_id) / "completion-target.md" + text = path.read_text(encoding="utf-8") if path.is_file() else "" + return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))] + + def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]: + errors: list[dict[str, str]] = [] + for requirement_index, requirement in enumerate(output.requirements): + for claim_index, claim in enumerate(requirement.acceptance_claims): + try: + messages = self.registry.validate_expected(claim.claim_kind, claim.expected) + except ValueError: + messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] + errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages) + return errors + + def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]: + normalized = output.model_copy(deep=True) + warnings: list[str] = [] + for target, requirement in zip(targets, normalized.requirements, strict=True): + normalized_claims = [] + for claim in requirement.acceptance_claims: + if claim.claim_kind in _RECORD_BOUND_CLAIMS: + warnings.append( + f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review." + ) + claim.claim_kind = "visual" + claim.expected = {"description": target[:360]} + normalized_claims.append(claim) + continue + if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target): + self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim) + warnings.append( + f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope." + ) + continue + claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected) + normalized_claims.append(claim) + requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({ + "claim_kind": "visual", + "expected": {"description": target[:360]}, + })] + return normalized, list(dict.fromkeys(warnings)) @staticmethod - def _restore(payload: dict[str, Any]) -> Accepted | Waiting: - if payload.get("result_type") == "waiting": - error = payload.get("error") if isinstance(payload.get("error"), dict) else {} - return Waiting(WorkflowError( - ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)), - str(error.get("message") or "Requirements need a user decision."), - tuple(error.get("field_errors") or ()), - bool(error.get("retryable")), - dict(error.get("details") or {}), - )) + def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool: + if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z": + return False + if RequirementsCommandHandler._target_describes_finished_envelope(target): + return False + return any( + getattr(item, "claim_kind", "") == "outer_cylindrical_surface" + for item in claims + ) + + @staticmethod + def _target_describes_finished_envelope(target: str) -> bool: + lowered = target.lower() + return any(token in lowered for token in ( + "overall", + "total", + "finished part", + "entire part", + "whole part", + "bounding box", + "envelope", + "总", + "整体", + "成品", + "全高", + "包围盒", + )) + + @staticmethod + def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None: + value = bbox_claim.expected.get("value") + if not isinstance(value, (int, float)): + return + for item in claims: + if getattr(item, "claim_kind", "") != "outer_cylindrical_surface": + continue + expected = getattr(item, "expected", None) + if not isinstance(expected, dict) or "axial_span_mm" in expected: + continue + expected["axial_span_mm"] = value + if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)): + expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"] + return + + @staticmethod + def _validate_requirements_document(markdown: str) -> list[dict[str, str]]: + # Markdown is a human-facing semantic artifact. Its content is frozen + # verbatim and is not executable, so headings are guidance for the + # author rather than a server-enforced protocol. + return [] + + @staticmethod + def _validate_completion_target(markdown: str) -> list[dict[str, str]]: + values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))] + errors: list[dict[str, str]] = [] + if not values: + errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."}) + if len(values) != len(set(values)): + errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."}) + return errors + + @staticmethod + def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]: + return [] + + def _replay(self, task_id: str, invocation_id: str) -> Accepted | None: + invocation = self.repository.get_invocation(task_id, invocation_id) + return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None + + @staticmethod + def _restore(payload: dict[str, Any]) -> Accepted: return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload) - def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool: - payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"result_type": "accepted", "payload": result.payload} - return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result=payload) + def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool: + return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload}) @staticmethod def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str: encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest() - - @staticmethod - def _requirements_markdown(contract: dict[str, Any]) -> str: - rows = ["# Requirements", "", str(contract.get("summary") or ""), ""] - assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)] - if assumptions: - rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""]) - rows.extend(["## Requirements", ""]) - for item in contract.get("requirements") or (): - if not isinstance(item, dict): - continue - rows.append(f"- {item.get('statement')}") - rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ()) - return "\n".join(rows).rstrip() + "\n" - - @staticmethod - def _completion_target_markdown(contract: dict[str, Any]) -> str: - rows = ["# Completion Target", ""] - for requirement in contract.get("requirements") or (): - if not isinstance(requirement, dict): - continue - rows.append(f"## {requirement.get('statement')}") - for claim in requirement.get("acceptance_claims") or (): - if isinstance(claim, dict): - rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}") - rows.append("") - return "\n".join(rows).rstrip() + "\n" + return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest() @staticmethod def _stale() -> WorkflowError: @@ -312,9 +292,5 @@ class RequirementsCommandHandler: def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected: waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - self.repository.compare_and_swap(waiting, events=[{ - "event": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - }]) + self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}]) return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True)) diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 3b64001f..54bf8e20 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -17,11 +17,12 @@ from pydantic import BaseModel from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.llm_contracts import ( - CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction, - RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview, + CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction, + RollbackCheckpoint, StatelessCandidateReview, StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint, StatelessTopologyRequest, TopologyRequest, canonical_json_object, canonical_validate, canonical_validate_schema, + sanitize_compiled_requirements_arguments, stateless_final_review_schema, stateless_next_action_schema, stateless_rollback_checkpoint_schema, raw_arguments_hash, validate_one_tool_call, @@ -230,16 +231,6 @@ class WorkflowCoordinator: if state is None: yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."} return - if state.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - try: - self.requirements.ensure_rendered_contract_views(task_id, state) - except Exception as error: - yield self._service_failure( - task_id, - state, - WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True), - ) - return if state.phase == TaskPhase.COMPLETED: yield "task_terminal", self._projected_terminal(task_id, state) return @@ -264,7 +255,7 @@ class WorkflowCoordinator: yield self._service_failure(task_id, state, recovered.error) return continue - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS: + if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: image_paths = self.artifacts.source_image_paths(task_id) if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None: terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") @@ -299,8 +290,8 @@ class WorkflowCoordinator: }]) yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"} continue - spec_schema = self.requirements.spec_schema() - tools = [self._tool("submit_requirements_spec", spec_schema)] + document_schema = self.requirements.document_schema() + tools = [self._tool("write_requirements_document", document_schema)] terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") if terminal: yield terminal @@ -321,8 +312,8 @@ class WorkflowCoordinator: return continue name, raw, usage = result - validation = canonical_validate(raw, RequirementsAuthorOutput) - dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None + validation = canonical_validate(raw, MarkdownDocument) + dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None if dynamic_error is not None: validation = dynamic_error if isinstance(validation, WorkflowError): @@ -333,7 +324,7 @@ class WorkflowCoordinator: return continue invocation_id = self._invocation_id(task_id) - command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id) + command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id) if isinstance(command, Rejected): terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback) yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) @@ -341,7 +332,106 @@ class WorkflowCoordinator: yield terminal return continue - yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage) + event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) + event_payload["markdown"] = validation.markdown + yield "requirements_document_ready", event_payload + feedback = [] + continue + if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}: + document_schema = self.requirements.document_schema() + tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan" + tools = [self._tool(tool_name, document_schema)] + terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") + if terminal: + yield terminal + return + call_budget.record_attempt("author") + result = await self._author_turn(task_id, active_author, tools, feedback) + if isinstance(result, WorkflowError): + if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: + terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) + yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} + if terminal: + yield terminal + return + continue + active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) + if terminal: + yield terminal + return + continue + name, raw, usage = result + validation = canonical_validate(raw, MarkdownDocument) + dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None + if dynamic_error is not None: + validation = dynamic_error + if isinstance(validation, WorkflowError): + terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name) + yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) + if terminal: + yield terminal + return + continue + command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_plan(task_id, validation, invocation_id=self._invocation_id(task_id)) + if isinstance(command, Rejected): + terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool=tool_name) + yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) + if terminal: + yield terminal + return + continue + event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) + event_payload["markdown"] = validation.markdown + yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload + feedback = [] + continue + if state.phase == TaskPhase.COMPILING_REQUIREMENTS: + compiler_schema = self.requirements.compiler_schema(task_id) + tools = [self._tool("compile_requirements_spec", compiler_schema)] + terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author") + if terminal: + yield terminal + return + call_budget.record_attempt("author") + result = await self._author_turn(task_id, active_author, tools, feedback) + if isinstance(result, WorkflowError): + if result.code == ErrorCode.AUTHOR_FORMAT_INVALID: + terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback) + yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()} + if terminal: + yield terminal + return + continue + active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted) + if terminal: + yield terminal + return + continue + name, raw, usage = result + sanitized_raw = sanitize_compiled_requirements_arguments(raw) + if isinstance(sanitized_raw, WorkflowError): + validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw + else: + validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec) + dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None + if dynamic_error is not None: + validation = dynamic_error + if isinstance(validation, WorkflowError): + terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec") + yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) + if terminal: + yield terminal + return + continue + command = self.requirements.submit_compiled_spec(task_id, validation, invocation_id=self._invocation_id(task_id)) + if isinstance(command, Rejected): + terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="compile_requirements_spec") + yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) + if terminal: + yield terminal + return + continue + yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage) feedback = [] continue if state.phase == TaskPhase.AWAITING_ACTION: @@ -472,11 +562,7 @@ class WorkflowCoordinator: continue name, raw, usage = result if name == "record_geometry_conclusion": - diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state))) - validation = canonical_validate(raw, GeometryConclusion) - dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error + validation = canonical_validate(raw, StatelessGeometryConclusion) if isinstance(validation, WorkflowError): terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) @@ -484,6 +570,13 @@ class WorkflowCoordinator: yield terminal return continue + validation = GeometryConclusion( + working_head=state.working_head, + evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), + root_cause=validation.root_cause, + decision=validation.decision, + corrective_intent=validation.corrective_intent, + ) command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) if isinstance(command, Rejected): terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) @@ -675,6 +768,11 @@ class WorkflowCoordinator: yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)} continue state = self.repository.get_state(task_id) + terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None + if terminal: + yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} + yield terminal + return if state: failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}]) @@ -683,6 +781,11 @@ class WorkflowCoordinator: yield self._storage_failure(task_id, str(error)) except Exception as error: state = self.repository.get_state(task_id) + terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None + if terminal: + yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} + yield terminal + return if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}]) @@ -711,6 +814,15 @@ class WorkflowCoordinator: return None details = budget.payload() details["next_actor"] = actor + terminal = self._best_effort_terminal( + task_id, + state, + ErrorCode.CALL_BUDGET_EXHAUSTED, + "Configured model-call budget is exhausted; publishing the last executable checkpoint.", + budget=details, + ) + if terminal: + return terminal failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED) self.repository.compare_and_swap(failed, events=[{ "event": "call_budget_exhausted", @@ -728,6 +840,49 @@ class WorkflowCoordinator: "userActionRequired": False, } + def _best_effort_terminal( + self, + task_id: str, + state: TaskState, + reason: ErrorCode, + message: str, + *, + budget: dict[str, Any] | None = None, + ) -> tuple[str, dict[str, Any]] | None: + if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + return None + claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) + visual_claims = [ + {"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."} + for item in claim_results + if not item.get("deterministic") + ] + try: + self.requirements.write_completion_result( + task_id, + state, + claim_results=claim_results, + review={"visual_claims": visual_claims}, + ) + except OSError: + return None + completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id)) + if isinstance(completed, Rejected): + return None + return "task_terminal", { + "taskId": task_id, + "lifecycle": "completed", + "revisionId": state.active_revision, + "code": ErrorCode.BEST_EFFORT_COMPLETED.value, + "message": message, + "issues": completed.payload.get("issues") or [], + "verificationStatus": "completed_with_risks", + "verificationWarnings": completed.payload.get("issues") or [], + "completionResultPath": "completion-result.md", + "budget": budget or {}, + "userActionRequired": False, + } + def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]: """Park a nonterminal task when a durable artifact operation fails.""" state = self.repository.get_state(task_id) @@ -1045,6 +1200,8 @@ class WorkflowCoordinator: return [self._tool("inspect_topology", StatelessTopologyRequest)] references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) description = "Submit exactly one CDSL feature for the pending action." + if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}: + description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane." if action.atomic_id.startswith("hole_"): description += ( " Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. " @@ -1057,7 +1214,7 @@ class WorkflowCoordinator: "feature.selector_tokens is required author input: copy one of the opaque tokens from " "inspect_topology; the server resolves it to the host after validation." ) - fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references))}} + fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}} return [fragment] def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: @@ -1076,18 +1233,41 @@ class WorkflowCoordinator: state = self.repository.get_state(task_id) if state is None: return [] - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS: + if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: content = { - "protocol": "cad.v3.spec.v1", + "protocol": "cad.v3.1.markdown-first", "source_requirements": self.artifacts.read_source_requirements(task_id), "image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"), "user_clarifications": self._user_clarifications(task_id), "instruction": ( - "Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. " - "Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. " - "Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value." + "Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. " + "Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers." ), } + elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET: + content = { + "protocol": "cad.v3.1.markdown-first", + "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), + "instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.", + } + elif state.phase == TaskPhase.COMPILING_REQUIREMENTS: + content = { + "protocol": "cad.v3.1.markdown-first", + "source_requirements": self.artifacts.read_source_requirements(task_id), + "image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"), + "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), + "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), + "verifier_registry": self.requirements.registry.expected_one_of_schema(), + "instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.", + } + elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN: + content = { + "protocol": "cad.v3.1.markdown-first", + "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), + "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), + "compiled_contract": self._requirements_contract(task_id, state), + "instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.", + } else: contract = self._requirements_contract(task_id, state) or {} compact = [{ @@ -1117,7 +1297,7 @@ class WorkflowCoordinator: for token, value in tokens.items() if token in allowed ][:16] instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated." - content = {"protocol": "cad.v3", "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction} + content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction} messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] return [*messages, *feedback[-2:]] @@ -1133,6 +1313,15 @@ class WorkflowCoordinator: clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text}) return clarifications + def _read_markdown(self, task_id: str, relative_path: str) -> str: + if not relative_path: + return "" + try: + path = self.artifacts.artifact_path(task_id, relative_path) + return path.read_text(encoding="utf-8") if path.is_file() else "" + except (OSError, ValueError): + return "" + def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: """Expose the persisted requirement question when a task is parked. @@ -1232,6 +1421,18 @@ class WorkflowCoordinator: action = state.pending_action return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None + @staticmethod + def _coordinate_protocol(state: TaskState) -> dict[str, str]: + protocol = { + "system": "world_mm_right_handed", + "sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.", + "vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.", + "hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.", + } + if not state.active_revision: + protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y." + return protocol + @staticmethod def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(payload, dict): @@ -1265,7 +1466,7 @@ class WorkflowCoordinator: topology = self.artifacts.read_topology(task_id, state.active_revision) tokens = self.runtime.selector_tokens(topology) references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references))} + return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))} def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: constraints: list[dict[str, Any]] = [] @@ -1334,10 +1535,25 @@ class WorkflowCoordinator: def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: """Bound no-side-effect model rejections; park real service failures.""" + if error.code == ErrorCode.NO_PROGRESS_LIMIT: + current = self.repository.get_state(task_id) or state + terminal = self._best_effort_terminal( + task_id, + current, + ErrorCode.NO_PROGRESS_LIMIT, + "Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.", + ) + if terminal: + return terminal + feedback[:] = [self._feedback(error)] + return None if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID: # Registry integrity is a deployment defect. Retrying a model with # the same broken contract cannot repair it and must never consume # the author-format budget. + terminal = self._best_effort_terminal(task_id, state, error.code, error.message) + if terminal: + return terminal failed = transition(state, "failed", error=error.code) self.repository.compare_and_swap(failed, events=[{ "event": "runtime_contract_invalid", @@ -1350,6 +1566,27 @@ class WorkflowCoordinator: "code": error.code.value, "message": error.message, } + if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID: + # A frozen verifier contract is service-owned input at this stage. + # CAD retries cannot repair it, so preserve the terminal diagnosis + # instead of parking the task or blaming the author fragment. + current = self.repository.get_state(task_id) + if current is not None and current.phase != TaskPhase.FAILED: + failed = transition(current, "failed", error=error.code) + self.repository.compare_and_swap(failed, events=[{ + "event": "requirements_contract_execution_failed", + "tool": name, + "message": error.message, + }]) + return "task_terminal", { + "taskId": task_id, + "lifecycle": "failed", + "code": error.code.value, + "message": error.message, + "blockerType": "requirements_contract_invalid", + "userActionRequired": False, + "issues": [str(error.details.get("diagnostic") or error.message)], + } if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED: # A schema-valid fragment can still be impossible on the current # geometry. This is not an author-format failure: preserve the @@ -1368,8 +1605,8 @@ class WorkflowCoordinator: if event.get("failure_class_fingerprint") == failure_class_fingerprint ] if len(prior) >= 2: - failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(failed, events=[{ + next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT) + self.repository.compare_and_swap(next_state, events=[{ "event": "no_progress_limit", "tool": name, "code": ErrorCode.NO_PROGRESS_LIMIT.value, @@ -1379,15 +1616,16 @@ class WorkflowCoordinator: "normalized_error_code": normalized_code, "failure_class_fingerprint": failure_class_fingerprint, }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.", - "blockerType": "no_progress_limit", - "userActionRequired": False, - "revisionId": state.active_revision, - } + terminal = self._best_effort_terminal( + task_id, + next_state, + ErrorCode.NO_PROGRESS_LIMIT, + "The same operation failure made no progress; publishing the last executable checkpoint.", + ) + if terminal: + return terminal + feedback[:] = [self._feedback(error)] + return None next_state = transition( state, "runtime_precondition_rejected", @@ -1419,6 +1657,9 @@ class WorkflowCoordinator: return None # A precondition result outside fragment submission is an invalid # workflow implementation state, not a provider/service outage. + terminal = self._best_effort_terminal(task_id, state, error.code, error.message) + if terminal: + return terminal failed = transition(state, "failed", error=error.code) self.repository.compare_and_swap(failed, events=[{ "event": "runtime_precondition_rejected", @@ -1432,6 +1673,16 @@ class WorkflowCoordinator: "code": error.code.value, "message": error.message, } + if error.code in { + ErrorCode.CANDIDATE_BUILD_FAILED, + ErrorCode.CLAIM_VERIFICATION_FAILED, + ErrorCode.CANDIDATE_REVIEW_REJECTED, + }: + # The handler already preserved the last checkpoint and recorded + # the field/runtime diagnostic. Continue with that evidence; a + # planning miss is not an infrastructure terminal condition. + feedback[:] = [self._feedback(error)] + return None model_rejection_codes = { ErrorCode.AUTHOR_FORMAT_INVALID, ErrorCode.AUTHOR_DECISION_REJECTED, @@ -1446,6 +1697,14 @@ class WorkflowCoordinator: feedback[:] = [self._feedback(error)] if counters[name] < self.config.format_error_limit: return None + terminal = self._best_effort_terminal( + task_id, + state, + ErrorCode.FAILED_AUTHOR_FORMAT, + f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.", + ) + if terminal: + return terminal failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT) self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}]) return "task_terminal", { @@ -1464,6 +1723,8 @@ class WorkflowCoordinator: error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], + *, + tool: str = "compile_requirements_spec", ) -> tuple[str, dict[str, Any]] | None: key = "requirements_spec" counters[key] = counters.get(key, 0) + 1 @@ -1482,7 +1743,7 @@ class WorkflowCoordinator: "lifecycle": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, "message": "Requirements specification remained unreadable after one field-level correction.", - "tool": "submit_requirements_spec", + "tool": tool, "field_errors": list(error.field_errors), "userActionRequired": False, } @@ -1494,10 +1755,12 @@ class WorkflowCoordinator: error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], + *, + tool: str = "compile_requirements_spec", ) -> tuple[str, dict[str, Any]] | None: if error.retryable or error.code == ErrorCode.STORAGE_FAILURE: return self._service_failure(task_id, state, error) - return self._requirements_format_failure(task_id, state, error, counters, feedback) + return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool) @staticmethod def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index e6fb469e..9efd999a 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import shutil from app.cad_agent.adapters.artifact_store import FileArtifactStore from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher @@ -30,6 +31,10 @@ class V3Services: def compose_v3(settings: Settings) -> V3Services: repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3") + if repository.protocol_reset and settings.task_root.exists(): + # Protocol 3.1 has no valid interpretation for structured-only task + # artifacts, so clear that task root together with its old database. + shutil.rmtree(settings.task_root) artifacts = FileArtifactStore(settings.task_root) runtime = ProfileCadRuntime(settings) registry = default_registry() diff --git a/backend/app/cad_agent/domain/errors.py b/backend/app/cad_agent/domain/errors.py index cae51a4a..a585b7c7 100644 --- a/backend/app/cad_agent/domain/errors.py +++ b/backend/app/cad_agent/domain/errors.py @@ -29,6 +29,7 @@ class ErrorCode(StrEnum): REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID" NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT" RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE" + BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED" FAILED_INTERNAL = "FAILED_INTERNAL" WAITING_FOR_USER = "WAITING_FOR_USER" diff --git a/backend/app/cad_agent/domain/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 26bf0426..f2cde698 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -131,7 +131,7 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: raise OperationContractError("Operation candidate verifiers are duplicated") -def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> dict[str, Any]: +def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]: """Build the one-operation schema exposed for one pending action.""" validate_operation_contract(contract) shape = contract["fragment_shape"] @@ -174,15 +174,15 @@ def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], ref properties: dict[str, Any] = {"feature": feature} required = ["feature"] if shape["sketch"] == "required": - properties["sketch"] = _sketch_schema() + properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"}) required.insert(0, "sketch") schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False} Draft202012Validator.check_schema(schema) return schema -def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> list[dict[str, str]]: - schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens) +def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]: + schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum) return [ {"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message} for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message)) @@ -193,10 +193,23 @@ def _point(size: int) -> dict[str, Any]: return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size} -def _sketch_schema() -> dict[str, Any]: +def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]: point2 = _point(2) point3 = _point(3) - workplane = {"type": "object", "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, "required": ["origin_mm", "x_dir", "normal"], "additionalProperties": False} + workplane = { + "type": "object", + "description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.", + "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, + "required": ["origin_mm", "x_dir", "normal"], + "additionalProperties": False, + } + if root_xy_datum: + workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined." + workplane["properties"] = { + "origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3}, + "x_dir": {"const": [1, 0, 0]}, + "normal": {"const": [0, 0, 1]}, + } profile = { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False}, diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index bb38aa5f..39bf20a2 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -9,7 +9,10 @@ from .errors import ErrorCode, WorkflowError class TaskPhase(StrEnum): - DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS" + DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT" + DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET" + COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS" + DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN" AWAITING_ACTION = "AWAITING_ACTION" ACTION_PENDING = "ACTION_PENDING" CANDIDATE_BUILDING = "CANDIDATE_BUILDING" @@ -47,6 +50,9 @@ class TaskState: last_error: ErrorCode | None = None retry_from_phase: TaskPhase | None = None requirements_spec_path: str = "" + requirements_document_path: str = "" + completion_target_path: str = "" + modeling_plan_path: str = "" clarification_path: str = "" requirements_contract_path: str = "" @@ -58,13 +64,16 @@ class TaskState: # Legal state transitions. Events are intentionally terse persistence-neutral # names used by command handlers and architecture tests. _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = { - (TaskPhase.DRAFTING_REQUIREMENTS, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS, - (TaskPhase.DRAFTING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION, - (TaskPhase.DRAFTING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, + (TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, + (TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET, + (TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS, + (TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.DRAFTING_MODELING_PLAN, + (TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION, + (TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, # User clarifications are durable task evidence. Resume on the same task # so its frozen request remains authoritative # instead of turning a clarification into a new CAD request. - (TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS, + (TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, (TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING, (TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION, (TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION, @@ -83,6 +92,11 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = { (TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED, (TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION, } +_TRANSITIONS.update({ + (phase, "best_effort_completed"): TaskPhase.COMPLETED + for phase in TaskPhase + if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} +}) _TRANSITIONS.update({ (phase, "failed"): TaskPhase.FAILED for phase in TaskPhase @@ -94,7 +108,10 @@ _TRANSITIONS.update({ if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} }) _RETRY_RESUMABLE_PHASES = frozenset({ - TaskPhase.DRAFTING_REQUIREMENTS, + TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, + TaskPhase.DRAFTING_COMPLETION_TARGET, + TaskPhase.COMPILING_REQUIREMENTS, + TaskPhase.DRAFTING_MODELING_PLAN, TaskPhase.AWAITING_ACTION, TaskPhase.ACTION_PENDING, TaskPhase.CANDIDATE_BUILDING, @@ -122,7 +139,7 @@ def retry_resume_event(state: TaskState) -> str | None: return f"resume_{state.retry_from_phase.value.lower()}" -def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState: +def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState: """Apply one legal transition and advance optimistic-concurrency version.""" target = _TRANSITIONS.get((state.phase, event)) if target is None: @@ -144,6 +161,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction | last_error=error, retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None, requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path, + requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path, + completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path, + modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path, clarification_path=state.clarification_path if clarification_path is None else clarification_path, requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path, ) diff --git a/backend/app/cad_agent/domain/verifier_registry.py b/backend/app/cad_agent/domain/verifier_registry.py index 97a281d8..ed13ba58 100644 --- a/backend/app/cad_agent/domain/verifier_registry.py +++ b/backend/app/cad_agent/domain/verifier_registry.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from copy import deepcopy from math import atan2, isclose, pi, sqrt from typing import Any, Callable @@ -12,6 +13,7 @@ from jsonschema.exceptions import SchemaError ClaimResult = dict[str, Any] ClaimEvaluator = Callable[[dict[str, Any], dict[str, Any]], ClaimResult] +_DEFAULT_TOLERANCE_MM = 0.1 def _closed_object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: @@ -49,7 +51,13 @@ def _cylinder_axis_span(record: dict[str, Any]) -> float | None: def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | None: - """Return the inclusive axial interval of a cylindrical face's bounds.""" + """Return the inclusive axial interval of a cylindrical face's bounds. + + B-rep face orientation is not a physical property of a cylindrical shell. + In particular, a two-sided extrusion can return its two half-walls with + opposite axis directions. Use one canonical direction for an undirected + cylinder axis so those halves share the same coordinate interval. + """ geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} bbox = geometry.get("bbox_mm") direction = _cylinder_axis_direction(record) @@ -60,6 +68,9 @@ def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | Non maximum = tuple(float(value) for value in bbox[3:]) except (TypeError, ValueError): return None + dominant_index = max(range(3), key=lambda index: abs(direction[index])) + if direction[dominant_index] < 0: + direction = tuple(-component for component in direction) projections = [ sum(direction[index] * point[index] for index in range(3)) for point in ( @@ -418,7 +429,7 @@ def _outer_cylindrical_surface(expected: dict[str, Any], facts: dict[str, Any]) max(interval[1] for interval in shell_intervals if interval is not None) - min(interval[0] for interval in shell_intervals if interval is not None) ) - tolerance = float(expected["tolerance_mm"]) + tolerance = float(expected.get("tolerance_mm", _DEFAULT_TOLERANCE_MM)) evidence.update({"axial_spans_mm": actual_spans, "tolerance_mm": tolerance}) return _pass(evidence) if all(abs(span - float(axial_span)) <= tolerance for span in actual_spans) else _fail({"diameter_mm": expected["diameter_mm"], "expected_axial_span_mm": axial_span, "actual_axial_spans_mm": actual_spans, "tolerance_mm": tolerance}) @@ -790,11 +801,13 @@ class VerifierRegistry: except KeyError as error: raise ValueError(f"VERIFIER_UNAVAILABLE: {claim_kind}") from error - def expected_one_of_schema(self) -> dict[str, Any]: + def expected_one_of_schema(self, *, exclude_claim_kinds: set[str] | frozenset[str] | tuple[str, ...] = ()) -> dict[str, Any]: + excluded = set(exclude_claim_kinds) return { "oneOf": [ _closed_object({"claim_kind": {"const": item.claim_kind}, "expected": item.expected_schema}, ["claim_kind", "expected"]) for item in self._definitions.values() + if item.claim_kind not in excluded ] } @@ -806,6 +819,19 @@ class VerifierRegistry: for error in sorted(validator.iter_errors(expected), key=lambda item: (list(item.absolute_path), item.message)) ] + def normalize_expected(self, claim_kind: str, expected: dict[str, Any]) -> dict[str, Any]: + """Apply protocol defaults before a compiled contract is frozen. + + These defaults describe verifier mechanics, never user geometry. The + outer-cylinder verifier can match a diameter without a tolerance, but + measuring its optional axial span needs one. Persist the default so + the resulting contract is complete and independently reproducible. + """ + normalized = deepcopy(expected) + if claim_kind == "outer_cylindrical_surface" and "axial_span_mm" in normalized: + normalized.setdefault("tolerance_mm", _DEFAULT_TOLERANCE_MM) + return normalized + def evaluate(self, claim_kind: str, expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult: errors = self.validate_expected(claim_kind, expected) if errors: diff --git a/backend/app/cad_agent/evals/usable_smoke.py b/backend/app/cad_agent/evals/usable_smoke.py new file mode 100644 index 00000000..39b9bda4 --- /dev/null +++ b/backend/app/cad_agent/evals/usable_smoke.py @@ -0,0 +1,164 @@ +"""Run real-provider CAD tasks with a usable-model success criterion. + +This evaluator is intentionally narrower than the release gate in ``live.py``. +It answers one operational question: can the current workflow reliably finish +ordinary prompts with a downloadable/previewable checkpoint, even if some +acceptance claims remain best-effort warnings. +""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import replace +from datetime import datetime, timezone +import json +from pathlib import Path +import secrets +import sys +from typing import Any + +from app.cad_agent.application.workflow import ModelIdentity +from app.cad_agent.composition import compose_v3 +from app.settings import BACKEND_ROOT, get_settings + + +DEFAULT_PROMPTS = ( + "生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。", + "生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。", + "生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。", +) + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.") + parser.add_argument("--author-provider") + parser.add_argument("--author-model") + parser.add_argument("--review-provider") + parser.add_argument("--review-model") + parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.") + parser.add_argument("--max-wall-seconds", type=int, default=1200) + parser.add_argument("--max-model-calls", type=int, default=60) + return parser.parse_args() + + +def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str: + terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {}) + for source in (terminal, projection): + code = source.get("code") or source.get("last_error") if isinstance(source, dict) else "" + if isinstance(code, str) and code: + return code + return "" + + +def _artifact_ok(artifact_root: Path, revision_id: str) -> bool: + if not revision_id: + return False + revision_root = artifact_root / "revisions" / revision_id + return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json")) + + +def _classify_failure(code: str, projection: dict[str, Any]) -> str: + if str(projection.get("lifecycle") or "") == "waiting_retry": + return "configuration_or_service" + if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}: + return "code_or_flow" + if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}: + return "configuration_or_service" + if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}: + return "model_output_or_best_effort" + return "unknown" + + +async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: + settings = get_settings() + try: + author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model) + if arguments.review_provider or arguments.review_model: + review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model) + else: + review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model) + except ValueError as error: + return {"status": "blocked", "error": str(error), "results": []} + isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations") + services = compose_v3(isolated) + services.workflow.config = replace( + services.workflow.config, + max_turns=max(8, arguments.max_model_calls * 3), + max_model_calls=arguments.max_model_calls, + ) + prompts = tuple(arguments.prompt or DEFAULT_PROMPTS) + results: list[dict[str, Any]] = [] + for index, prompt in enumerate(prompts, start=1): + task_id = f"cad_{secrets.token_hex(6)}" + services.workflow.create_task(task_id, prompt) + events: list[dict[str, Any]] = [] + started = datetime.now(timezone.utc) + try: + async with asyncio.timeout(arguments.max_wall_seconds): + async for name, payload in services.workflow.run( + task_id=task_id, + author=ModelIdentity(author_provider.id, author_model.id), + reviewer=ModelIdentity(review_provider.id, review_model.id), + ): + events.append({"name": name, "payload": payload}) + except TimeoutError: + events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}}) + projection = services.repository.get_task_projection(task_id) or {} + artifact_root = (isolated.task_root / task_id).resolve() + revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "") + code = _result_code(events, projection) + usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id) + results.append({ + "index": index, + "task_id": task_id, + "prompt": prompt, + "success": usable, + "failure_layer": "" if usable else _classify_failure(code, projection), + "code": code, + "phase": projection.get("phase"), + "lifecycle": projection.get("lifecycle"), + "active_revision": revision_id, + "verification_status": projection.get("verification_status"), + "artifact_root": str(artifact_root), + "duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000), + "usage": services.repository.usage_summary(task_id), + "event_audit": [ + { + "name": item.get("name"), + "status": (item.get("payload") or {}).get("status"), + "lifecycle": (item.get("payload") or {}).get("lifecycle"), + "code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"), + "tool": (item.get("payload") or {}).get("tool"), + } + for item in events + ], + }) + return { + "status": "passed" if results and all(item["success"] for item in results) else "failed", + "schema_version": "cad.usable-smoke.v1", + "author": {"provider": author_provider.id, "model": author_model.id}, + "reviewer": {"provider": review_provider.id, "model": review_model.id}, + "results": results, + } + + +def main() -> int: + arguments = _arguments() + report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + report_root.mkdir(parents=True, exist_ok=True) + try: + result = asyncio.run(_run(arguments, report_root)) + except KeyboardInterrupt: + raise + except BaseException as error: + result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []} + result.update({"report_root": str(report_root.resolve())}) + report_path = report_root / "usable-smoke-report.json" + report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False)) + return 0 if result["status"] == "passed" else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index bf664b74..cf47eef8 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -88,6 +88,7 @@ class CadRuntime(Protocol): def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ... def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ... def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ... + def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ... class ModelGateway(Protocol): diff --git a/backend/app/main.py b/backend/app/main.py index dd15f146..53bdb6f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -154,11 +154,16 @@ async def read_task(task_id: str) -> JSONResponse: task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) - task["requirements_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").is_file() else None - target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md" + task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"]) + requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None + task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None + target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None + plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md" - task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path.is_file() else None - task["completion_target_path"] = "completion-target.md" if target_path.is_file() else "" + task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None + task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else "" + task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None + task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_path.is_file() else "" task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None task["completion_result_path"] = "completion-result.md" if result_path.is_file() else "" task["usage"] = agent.v3.repository.usage_summary(safe_id) @@ -203,6 +208,22 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st return result +def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Expose server-evaluated checklist progress without parsing Markdown.""" + by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)} + progress: list[dict[str, Any]] = [] + for requirement in (contract or {}).get("requirements") or (): + if not isinstance(requirement, dict): + continue + statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)] + progress.append({ + "requirement_id": str(requirement.get("requirement_id") or ""), + "statement": str(requirement.get("statement") or ""), + "status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending", + }) + return progress + + @app.delete("/v1/tasks/{task_id}") async def cancel_task(task_id: str) -> JSONResponse: try: diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 4627d768..3d8b80c6 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -33,7 +33,10 @@ def _response_language(text: str) -> str: _EVENT_LABELS = { "image_observation": "参考图片观察", - "requirements_ready": "需求规格已就绪", + "requirements_document_ready": "需求文档已冻结", + "completion_target_ready": "完成目标已冻结", + "requirements_compiled": "需求合同已编译", + "modeling_plan_ready": "建模计划已冻结", "completion_result_ready": "完成结果已就绪", "model_protocol_check": "模型协议检查", "action_selection": "动作选择", diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py index 2891f6ba..228b54ee 100644 --- a/backend/engine/cdsl_engine/__init__.py +++ b/backend/engine/cdsl_engine/__init__.py @@ -8,7 +8,24 @@ from __future__ import annotations from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan -from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild +try: + from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild +except ModuleNotFoundError as exc: + # The legacy rebuild façade optionally depends on cdsl_importer, which is + # not present in the standalone runtime distribution. Keep importing the + # executable session runtime possible; callers of the legacy façade get a + # precise import error when they invoke it. + if exc.name != "cdsl_importer": + raise + + def _legacy_rebuild_unavailable(*_args, _missing=exc, **_kwargs): + raise RuntimeError("legacy rebuild facade requires the optional cdsl_importer package") from _missing + + compare_with_gold = _legacy_rebuild_unavailable + compile_cdsl_to_pack = _legacy_rebuild_unavailable + run_cdsl_only = _legacy_rebuild_unavailable + run_engine = _legacy_rebuild_unavailable + run_rebuild = _legacy_rebuild_unavailable from .semantic_validation import validate_semantic_cdsl from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py index 2121ca0e..dd551f66 100644 --- a/backend/tests/test_cad_agent_v3.py +++ b/backend/tests/test_cad_agent_v3.py @@ -20,11 +20,20 @@ from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.review_gateway import RenderedReviewGateway from app.cad_agent.adapters.runtime import ProfileCadRuntime from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.adapters.verifier import RegistryVerifierExecutor from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability +from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.llm_contracts import ( - RequirementsAuthorOutput, + CandidateReview, + CompiledRequirementsSpec, + MarkdownDocument, + NextAction, StatelessCandidateReview, - requirements_spec_schema, + StatelessGeometryConclusion, + canonical_validate, + canonical_validate_schema, + compiled_requirements_schema, + sanitize_compiled_requirements_arguments, stateless_final_review_schema, stateless_next_action_schema, stateless_rollback_checkpoint_schema, @@ -34,7 +43,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.results import Accepted, Rejected, Waiting from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator from app.cad_agent.domain.errors import ErrorCode -from app.cad_agent.domain.operation_contract import fragment_schema +from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition from app.cad_agent.domain.verifier_registry import default_registry from app.models.contracts import ChatMessage @@ -63,20 +72,48 @@ def settings(root: Path) -> Settings: ) -def ready_spec(*, spacing: float = 60.0) -> RequirementsAuthorOutput: - return RequirementsAuthorOutput.model_validate({ - "outcome": "ready", - "summary": "Circular flange with an eight-hole pattern.", - "assumptions": ["Dimensions use millimetres."], - "requirements": [{ - "statement": f"Use eight holes with a declared spacing of {spacing:g} degrees.", - "assumptions": [], - "acceptance_claims": [{ - "claim_kind": "visual", - "expected": {"description": f"Eight holes are shown with the requested {spacing:g} degree declaration."}, - }], - }], - }) +def requirements_document() -> MarkdownDocument: + return MarkdownDocument(markdown="""# Design Understanding + +Simple functional flange. + +# Explicit User Requirements + +- Create a simple flange. + +# Engineering Defaults and Assumptions + +- Use a circular body, central through bore, and four equally spaced mounting holes. + +# Dimensions and Coordinate Convention + +- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35. + +# Open Uncertainties + +- None. +""") + + +def completion_target() -> MarkdownDocument: + return MarkdownDocument(markdown="""# Completion Target + +- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness. +- [ ] Centered 30 mm through bore. +- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius. +""") + + +def compiled_flange() -> CompiledRequirementsSpec: + return CompiledRequirementsSpec.model_validate({"requirements": [ + {"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]}, + ]}) + + +def modeling_plan() -> MarkdownDocument: + return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n") def walk_keys(value: object) -> set[str]: @@ -94,36 +131,62 @@ def walk_keys(value: object) -> set[str]: class CadV3ProtocolTests(unittest.TestCase): def test_state_machine_has_no_requirements_review_phase(self) -> None: self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase}) - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0) - approved = transition(state, "requirements_approved", requirements_contract_path="documents/contract.json") + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) + document = transition(state, "requirements_document_written", requirements_document_path="requirements.md") + target = transition(document, "completion_target_written", completion_target_path="completion-target.md") + compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") + approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md") self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION) self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()}) def test_waiting_retry_resumes_exact_source_phase(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0) + state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements") + self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document") resumed = transition(waiting, retry_resume_event(waiting) or "") - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS) + self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) self.assertIsNone(resumed.retry_from_phase) def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None: with tempfile.TemporaryDirectory() as temporary: runtime = ProfileCadRuntime(settings(Path(temporary))) schemas = [ - requirements_spec_schema(default_registry().expected_one_of_schema()), + MarkdownDocument.model_json_schema(), + compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2), stateless_next_action_schema(list(runtime.supported_atomic_ids())), StatelessCandidateReview.model_json_schema(), + StatelessGeometryConclusion.model_json_schema(), stateless_final_review_schema(2), ] forbidden = { "task_id", "working_head", "requirement_id", "requirement_ids", "claim_id", "candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids", - "draft_id", "attachment_id", + "draft_id", "attachment_id", "record_ids", } for schema in schemas: self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden) + def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None: + schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1) + raw = json.dumps({ + "assumptions": ["top-level notes from the compiler are not executable"], + "requirements": [{ + "statement": "model-added copy of the checklist text", + "assumptions": [], + "acceptance_claims": [{ + "claim_kind": "single_connected_body", + "expected": {}, + "evidence": "not part of the compiler contract", + }], + }], + }) + sanitized = sanitize_compiled_requirements_arguments(raw) + self.assertIsInstance(sanitized, str) + self.assertIsNone(canonical_validate_schema(sanitized, schema)) + parsed = canonical_validate(sanitized, CompiledRequirementsSpec) + self.assertIsInstance(parsed, CompiledRequirementsSpec) + self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body") + def test_dynamic_tokens_are_enum_constrained(self) -> None: rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"]) self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]}) @@ -133,28 +196,363 @@ class CadV3ProtocolTests(unittest.TestCase): selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"] self.assertEqual(selector, {"enum": ["selector_one"]}) - def test_requirements_spec_freezes_once_and_preserves_user_value(self) -> None: + def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runtime = ProfileCadRuntime(settings(Path(temporary))) + contract = runtime.operation_contract("extrude_add_blind") + fragment = { + "sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}}, + "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}}, + } + self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True)) + fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6] + self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), []) + + def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + runtime = ProfileCadRuntime(settings(Path(temporary))) + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "partial_rebuild", + "geometry": {"sketches": [{ + "id": "sketch_001", + "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, + }]}, + "features": [ + {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"}, + {"id": "feature_002", "atomic_id": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]}, + ], + } + rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate") + self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"]) + self.assertEqual(len(failures), 1) + self.assertEqual(failures[0]["feature_id"], "feature_002") + + def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None: + class PassingVerifier: + def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]: + return [ + { + "claim_id": str(claim.get("claim_id") or ""), + "claim_kind": str(claim.get("claim_kind") or ""), + "deterministic": True, + "status": "pass", + "evidence": {}, + } + for claim in claims + ] + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + runtime = ProfileCadRuntime(settings(root)) + requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) + actions = ActionCommandHandler(repository, artifacts, runtime, PassingVerifier()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a flange.") + artifacts.initialize_task(task_id, "Create a flange.") + requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") + requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") + state = repository.get_state(task_id) + proposal = NextAction( + working_head=state.working_head, + intent="Create a two-feature batch.", + requirement_ids=["req_001"], + atomic_id="extrude_add_blind", + expected_change="Keep the executable part of the batch.", + ) + self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) + fragment = { + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 10}, + }, + "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}}, + } + cdsl = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "partial_batch", + "geometry": {"sketches": []}, + "features": [ + {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []}, + {"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]}, + ], + } + audit = { + "schema_version": "cad.v3.fragment-audit.v1", + "atomic_id": "extrude_add_blind", + "fragment_hash": "hash", + "contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"], + "assigned_feature_ids": ["feature_001", "feature_002"], + "assigned_sketch_ids": [], + "selector_snapshot_id": "", + "selector_tokens": [], + "reference_snapshot_id": "", + "reference_tokens": [], + } + rebuilt = { + "executed_feature_ids": ["feature_001"], + "health": {"solid_count": 1}, + "topology": {"records": []}, + "report": {}, + "render_manifest": {}, + "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"}, + } + operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}] + with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)): + result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment") + self.assertIsInstance(result, Accepted) + reviewing = repository.get_state(task_id) + self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW) + candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} + self.assertEqual(candidate["executed_feature_ids"], ["feature_001"]) + self.assertEqual(candidate["operation_failures"], operation_failures) + + def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None: + state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True) + completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) + self.assertEqual(completed.phase, TaskPhase.COMPLETED) + self.assertFalse(completed.repair_required) + + def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + runtime = ProfileCadRuntime(settings(root)) + requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) + actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry())) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a simple flange.") + artifacts.initialize_task(task_id, "Create a simple flange.") + requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") + requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") + state = repository.get_state(task_id) + proposal = NextAction( + working_head=state.working_head, + intent="Create the base body.", + requirement_ids=["req_001"], + atomic_id="extrude_add_blind", + expected_change="Create the circular body.", + ) + self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) + fragment = { + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 50}, + }, + "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}}, + } + self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted) + reviewing = repository.get_state(task_id) + candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} + review = CandidateReview( + candidate_id=reviewing.candidate_id, + working_head=reviewing.pending_action.working_head, + verdict="reject", + claim_coverage=[ + {"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []} + for item in candidate["claim_results"] + ], + evidence=["Base body is executable."], + issues=["The bore and bolt holes remain to be added."], + ) + result = actions.record_candidate_review(task_id, review, invocation_id="review") + self.assertIsInstance(result, Accepted) + self.assertEqual(result.payload["status"], "accepted_with_issues") + published = repository.get_state(task_id) + self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION) + self.assertEqual(published.active_revision, "rev_001") + self.assertTrue(published.repair_required) + completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort") + self.assertIsInstance(completed, Accepted) + self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED) + + def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + configured = settings(root) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + runtime = ProfileCadRuntime(configured) + initial = repository.create_task("cad_123456abcdef", "Create a flange.") + document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md") + target = transition(document, "completion_target_written", completion_target_path="completion-target.md") + compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") + awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True) + self.assertTrue(repository.compare_and_swap(document)) + self.assertTrue(repository.compare_and_swap(target)) + self.assertTrue(repository.compare_and_swap(compiled)) + self.assertTrue(repository.compare_and_swap(awaiting, events=[{ + "event": "geometry_conclusion", + "decision": "rollback", + "working_head": awaiting.working_head, + }])) + actions = ActionCommandHandler(repository, artifacts, runtime, default_registry()) + self.assertFalse(actions.rollback_available(awaiting.task_id)) + + def test_geometry_conclusion_is_stateless_for_the_author(self) -> None: + schema = StatelessGeometryConclusion.model_json_schema() + self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema)) + + def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None: + def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]: + return { + "record_id": record_id, + "geometry": { + "surface_type": "cylinder", + "cylinder_role": "outer", + "radius_mm": 60.0, + "axis_origin_mm": [0.0, 0.0, 0.0], + "axis_direction": direction, + "bbox_mm": bbox, + }, + } + + facts = {"topology": {"records": [ + outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]), + outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]), + ]}} + result = default_registry().evaluate( + "outer_cylindrical_surface", + {"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0}, + facts, + ) + self.assertEqual(result["status"], "pass") + self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0]) + self.assertEqual(result["evidence"]["tolerance_mm"], 0.1) + + def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = SqliteTaskRepository(root / "state.sqlite3") artifacts = FileArtifactStore(root / "tasks") handler = RequirementsCommandHandler(repository, artifacts, default_registry()) task_id = "cad_123456abcdef" - repository.create_task(task_id, "Use 8 holes around a full circle at 60 degrees.") - artifacts.initialize_task(task_id, "Use 8 holes around a full circle at 60 degrees.") - result = handler.submit_spec(task_id, ready_spec(spacing=60), invocation_id="requirements_once") + repository.create_task(task_id, "Create a flange.") + artifacts.initialize_task(task_id, "Create a flange.") + handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + compiled = CompiledRequirementsSpec.model_validate({"requirements": [ + {"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]}, + ]}) + self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) + state = repository.get_state(task_id) + contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} + first_claim = contract["requirements"][0]["acceptance_claims"][0] + self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1) + + def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a simple flange.") + artifacts.initialize_task(task_id, "Create a simple flange.") + handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + compiled = CompiledRequirementsSpec.model_validate({"requirements": [ + {"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]}, + {"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]}, + ]}) + self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) + state = repository.get_state(task_id) + contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} + claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]] + self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"]) + self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"]) + self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"])) + self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"])) + + def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a stepped hub adapter.") + artifacts.initialize_task(task_id, "Create a stepped hub adapter.") + handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target + +- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness. +"""), invocation_id="completion_target") + compiled = CompiledRequirementsSpec.model_validate({"requirements": [{ + "assumptions": [], + "acceptance_claims": [ + {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}}, + {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.1}}, + ], + }]}) + self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted) + state = repository.get_state(task_id) + contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} + claims = contract["requirements"][0]["acceptance_claims"] + self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"]) + self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12) + self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"])) + + def test_requirements_markdown_is_not_rejected_for_missing_headings(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a flange.") + artifacts.initialize_task(task_id, "Create a flange.") + result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown") self.assertIsInstance(result, Accepted) + self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET) + + def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None: + action = NextAction( + working_head="cad_123456abcdef:root:v4", + intent="Create the flange body.", + requirement_ids=[f"req_{position:03d}" for position in range(1, 8)], + atomic_id="extrude_add_blind", + expected_change="Add the first solid body.", + ) + self.assertEqual(len(action.requirement_ids), 7) + + def test_markdown_documents_freeze_before_compiled_flange_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = SqliteTaskRepository(root / "state.sqlite3") + artifacts = FileArtifactStore(root / "tasks") + handler = RequirementsCommandHandler(repository, artifacts, default_registry()) + task_id = "cad_123456abcdef" + repository.create_task(task_id, "Create a simple flange.") + artifacts.initialize_task(task_id, "Create a simple flange.") + self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted) + self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted) + self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted) + self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted) state = repository.get_state(task_id) self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION) contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - requirement = contract["requirements"][0] - self.assertIn("60 degrees", requirement["statement"]) - self.assertIn("60 degree", requirement["acceptance_claims"][0]["expected"]["description"]) - self.assertNotIn("45 degrees", requirement["statement"]) - self.assertNotIn("applied_normalizations", contract) + self.assertEqual(len(contract["requirements"]), 3) + claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]} + self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds)) self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file()) - self.assertFalse((artifacts.task_dir(task_id) / "completion.md").exists()) + self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file()) def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -163,44 +561,18 @@ class CadV3ProtocolTests(unittest.TestCase): artifacts = FileArtifactStore(root / "tasks") handler = RequirementsCommandHandler(repository, artifacts, default_registry()) task_id = "cad_123456abcdef" - initial = repository.create_task(task_id, "Create one solid.") + repository.create_task(task_id, "Create one solid.") artifacts.initialize_task(task_id, "Create one solid.") - value = RequirementsAuthorOutput.model_validate({ - "outcome": "ready", "summary": "One solid", "assumptions": [], - "requirements": [{"statement": "One solid", "assumptions": [], "acceptance_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 0}}, - ]}], - }) - result = handler.submit_spec(task_id, value, invocation_id="invalid_spec") + handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3}) + before = repository.get_state(task_id) + result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec") self.assertIsInstance(result, Rejected) self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID) - self.assertEqual(repository.get_state(task_id), initial) + self.assertEqual(repository.get_state(task_id), before) - def test_clarification_uses_same_task_and_one_question(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(settings(root)) - requirements = RequirementsCommandHandler(repository, artifacts, default_registry()) - workflow = WorkflowCoordinator(WorkflowConfig(4, 2), repository, artifacts, runtime, None, None, requirements, None) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Use four holes and use six holes.") - artifacts.initialize_task(task_id, "Use four holes and use six holes.") - value = RequirementsAuthorOutput.model_validate({ - "outcome": "clarification", "source_quotes": ["four holes", "six holes"], - "question": "Should the part use four holes or six holes?", - }) - result = requirements.submit_spec(task_id, value, invocation_id="clarify_once") - self.assertIsInstance(result, Waiting) - waiting = repository.get_state(task_id) - terminal = workflow.waiting_for_user_terminal(task_id, waiting) - self.assertEqual(terminal["questions"], ["Should the part use four holes or six holes?"]) - self.assertTrue(terminal["userActionRequired"]) - self.assertTrue(workflow.resume_with_user_clarification(task_id, "Use six holes.", message_id="user_reply")) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS) - - def test_contract_views_and_completion_result_share_one_spec(self) -> None: + def test_completion_result_reports_frozen_checklist(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) repository = SqliteTaskRepository(root / "state.sqlite3") @@ -209,20 +581,23 @@ class CadV3ProtocolTests(unittest.TestCase): task_id = "cad_123456abcdef" repository.create_task(task_id, "Create a coherent flange.") artifacts.initialize_task(task_id, "Create a coherent flange.") - handler.submit_spec(task_id, ready_spec(), invocation_id="requirements_once") + handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") + handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") + handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") + handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") state = repository.get_state(task_id) path = handler.write_completion_result( task_id, state, - claim_results=[{"claim_id": "claim_001", "deterministic": False, "status": "pending"}], - review={"visual_claims": [{"status": "pass", "evidence": "Reference and render match."}]}, + claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)], + review={"visual_claims": []}, ) self.assertEqual(path, "completion-result.md") result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8") target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8") requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8") - self.assertIn("eight-hole pattern", requirements) - self.assertIn("visual", target) - self.assertIn("visual: pass", result) + self.assertIn("Engineering Defaults", requirements) + self.assertIn("Centered 30 mm through bore", target) + self.assertIn("Centered 30 mm through bore.: pass", result) def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -231,6 +606,9 @@ class CadV3ProtocolTests(unittest.TestCase): with sqlite3.connect(database) as connection: columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} self.assertIn("requirements_spec_path", columns) + self.assertIn("requirements_document_path", columns) + self.assertIn("completion_target_path", columns) + self.assertIn("modeling_plan_path", columns) self.assertIn("clarification_path", columns) self.assertNotIn("requirements_draft_path", columns) self.assertNotIn("requirements_review_path", columns) @@ -242,7 +620,7 @@ class CadV3ProtocolTests(unittest.TestCase): reviewer = conformance_tools(runtime, role="reviewer") author_names = {item["function"]["name"] for item in author} reviewer_names = {item["function"]["name"] for item in reviewer} - self.assertIn("submit_requirements_spec", author_names) + self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names)) self.assertNotIn("review_requirements", author_names | reviewer_names) self.assertNotIn("get_cdsl_operation_contract", author_names) self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"}) @@ -336,7 +714,7 @@ class CadV3ProtocolTests(unittest.TestCase): )) self.assertIsNone(result) self.assertEqual(verify.await_count, 2) - self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS) + self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) events = [queue.get_nowait(), queue.get_nowait()] self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"]) diff --git a/cadfs_to_cdsl/.gitignore b/cadfs_to_cdsl/.gitignore new file mode 100644 index 00000000..179b4868 --- /dev/null +++ b/cadfs_to_cdsl/.gitignore @@ -0,0 +1,3 @@ +output/ +__pycache__/ +*.pyc diff --git a/cadfs_to_cdsl/README.md b/cadfs_to_cdsl/README.md new file mode 100644 index 00000000..7cdb1019 --- /dev/null +++ b/cadfs_to_cdsl/README.md @@ -0,0 +1,16 @@ +# CADFS to CDSL + +Converts the local CADFS FeatureScript RP corpus to self-contained CDSL 1.1, +rebuilds runtime-eligible documents with the current CDSL engine, and compares +the rebuilt B-rep with the source STEP. + +```bash +PYTHONPATH=backend:. python -m cadfs_to_cdsl scan +PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --sample-id 00000173 +PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --limit 100 --seed 0 +PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline +``` + +All stages are resumable. Use `--force` after changing converter behavior. +The original CADFS directory is read-only; generated evidence is written under +`cadfs_to_cdsl/output/samples//`. diff --git a/cadfs_to_cdsl/__init__.py b/cadfs_to_cdsl/__init__.py new file mode 100644 index 00000000..01668971 --- /dev/null +++ b/cadfs_to_cdsl/__init__.py @@ -0,0 +1,4 @@ +"""CADFS FeatureScript to CDSL conversion and validation pipeline.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/cadfs_to_cdsl/__main__.py b/cadfs_to_cdsl/__main__.py new file mode 100644 index 00000000..bfdcd0c1 --- /dev/null +++ b/cadfs_to_cdsl/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py new file mode 100644 index 00000000..c52af7fa --- /dev/null +++ b/cadfs_to_cdsl/cli.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse, json +from pathlib import Path +from .pipeline import load_samples, run_stage, scan, select_samples +from .reports import generate_reports, read_json + +DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test") +DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP") + commands = parser.add_subparsers(dest="command", required=True) + for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline"): + command = commands.add_parser(name) + command.add_argument("--input", type=Path, default=DEFAULT_INPUT) + command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + if name != "scan": + command.add_argument("--sample-id", action="append") + command.add_argument("--offset", type=int, default=0) + command.add_argument("--limit", type=int) + command.add_argument("--seed", type=int) + command.add_argument("--workers", type=int, default=1) + command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp") + command.add_argument("--force", action="store_true") + command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv); args.output.mkdir(parents=True, exist_ok=True) + if args.command == "scan": + records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")} + elif args.command == "report": + manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()] + result = generate_reports(args.output, records) + else: + if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8") + samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed) + if args.timeout_seconds <= 0: raise ValueError("--timeout-seconds must be positive") + records = run_stage(args.command, samples, args.output, force=args.force, compare_mode=args.compare_mode, timeout_seconds=args.timeout_seconds, workers=args.workers) + counts: dict[str, int] = {} + for record in records: counts[record["status"]] = counts.get(record["status"], 0) + 1 + result = {"sample_count": len(records), "statuses": counts, "summary": str(args.output / "summary.json")} + print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True)); return 0 diff --git a/cadfs_to_cdsl/compare.py b/cadfs_to_cdsl/compare.py new file mode 100644 index 00000000..027b253d --- /dev/null +++ b/cadfs_to_cdsl/compare.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +STRICT = {"surface_mm": 0.01, "bbox_mm": 0.01, "volume_relative": 1e-5, "area_relative": 1e-5} +RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relative": 0.005} + + +def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]: + surface = report["surface"]; metrics = report["metrics"] + maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"]) + p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"]) + checks = { + "surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"], + "bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"], + "volume": metrics["volume_relative_error"] <= limits["volume_relative"], + "area": metrics["surface_area_relative_error"] <= limits["area_relative"], + "solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"], + } + return {"passed": all(checks.values()), "limits": limits, "checks": checks} + + +def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]: + from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare + raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm) + strict, rp = _assess(raw, STRICT), _assess(raw, RP) + decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected" + return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision} diff --git a/cadfs_to_cdsl/dataset.py b/cadfs_to_cdsl/dataset.py new file mode 100644 index 00000000..361fde5c --- /dev/null +++ b/cadfs_to_cdsl/dataset.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import hashlib, json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +MODALITIES = { + "featurescript": ("featurescript_rp", ".txt"), + "step": ("step_abc", ".step"), + "stl": ("stl_abc", ".stl"), + "image": ("multiview_images_abc", ".png"), + "annotation": ("text_annotations", ".txt"), +} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) + return digest.hexdigest() + + +@dataclass +class Sample: + sample_id: str + files: dict[str, str] = field(default_factory=dict) + hashes: dict[str, str] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + diagnostics: list[dict[str, Any]] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: return asdict(self) + + +def _files(root: Path, directory: str, suffix: str) -> dict[str, Path]: + base = root / directory; result: dict[str, Path] = {} + if not base.exists(): return result + for path in base.rglob(f"*{suffix}"): + if path.is_file() and path.stem.isdigit(): result[path.stem] = path + return result + + +def _jsonl_records(path: Path) -> list[dict[str, Any]]: + if not path.exists(): return [] + records = [] + for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + try: + value = json.loads(line); value["_line"] = index; records.append(value) + except json.JSONDecodeError: + records.append({"_line": index, "_invalid": True}) + return records + + +def _assistant_script(record: dict[str, Any]) -> str | None: + for message in record.get("messages") or []: + if message.get("role") == "assistant" and isinstance(message.get("content"), str) and "FeatureScript" in message["content"]: + return message["content"] + return None + + +def scan_dataset(root: Path, *, include_hashes: bool = True) -> list[Sample]: + if not root.is_dir(): raise FileNotFoundError(f"CADFS input directory does not exist: {root}") + by_modality = {name: _files(root, directory, suffix) for name, (directory, suffix) in MODALITIES.items()} + ids = sorted(set().union(*(set(values) for values in by_modality.values()))) + text_records = _jsonl_records(root / "CADFS_text_test.jsonl") + image_records = _jsonl_records(root / "CADFS_image_test.jsonl") + text_by_hash = {hashlib.sha256(script.encode()).hexdigest(): record for record in text_records if (script := _assistant_script(record))} + samples: list[Sample] = [] + for index, sample_id in enumerate(ids): + sample = Sample(sample_id) + for name, values in by_modality.items(): + path = values.get(sample_id) + if path is None: sample.diagnostics.append({"code": "missing_modality", "modality": name}); continue + sample.files[name] = str(path) + if include_hashes: sample.hashes[name] = sha256(path) + fs_path = by_modality["featurescript"].get(sample_id) + if fs_path: + script_hash = hashlib.sha256(fs_path.read_text(encoding="utf-8").encode()).hexdigest() + record = text_by_hash.get(script_hash) + if record: + sample.metadata["text_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")} + elif index < len(text_records): + fallback = text_records[index]; sample.metadata["text_jsonl"] = {"line": fallback.get("_line"), "cad_file_id": fallback.get("cad_file_id"), "alignment": "fallback"} + sample.diagnostics.append({"code": "alignment_fallback", "modality": "text_jsonl"}) + if index < len(image_records): + record = image_records[index] + sample.metadata["image_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")} + samples.append(sample) + return samples diff --git a/cadfs_to_cdsl/diagnostics.py b/cadfs_to_cdsl/diagnostics.py new file mode 100644 index 00000000..ff348a8b --- /dev/null +++ b/cadfs_to_cdsl/diagnostics.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass +class Diagnostic: + code: str + message: str + severity: str = "error" + feature_id: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/cadfs_to_cdsl/featurescript_lexer.py b/cadfs_to_cdsl/featurescript_lexer.py new file mode 100644 index 00000000..dc3cfec9 --- /dev/null +++ b/cadfs_to_cdsl/featurescript_lexer.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re + + +@dataclass(frozen=True) +class Token: + kind: str + value: str + line: int + column: int + + +_RE = re.compile(r"(?P\s+)|(?P//[^\n]*|/\*.*?\*/)|(?P\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|(?P[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)|(?P[A-Za-z_][A-Za-z0-9_.]*)|(?P==|!=|<=|>=|=>|[{}\[\]():,;=+*/-])", re.S) + + +def lex(source: str) -> list[Token]: + out: list[Token] = [] + pos = 0; line = 1; column = 1 + while pos < len(source): + match = _RE.match(source, pos) + if not match: + raise SyntaxError(f"unexpected character at {line}:{column}: {source[pos]!r}") + kind, value = match.lastgroup or "", match.group(0) + if kind not in {"ws", "comment"}: + out.append(Token(kind, value, line, column)) + nl = value.count("\n") + if nl: line, column = line + nl, len(value.rsplit("\n", 1)[-1]) + 1 + else: column += len(value) + pos = match.end() + out.append(Token("eof", "", line, column)) + return out diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py new file mode 100644 index 00000000..3a360bd2 --- /dev/null +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any +from .featurescript_lexer import Token, lex +from .ir import Call, FeatureIR, ModelIR, SketchIR + + +def _string(value: Any) -> Any: + if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]: + try: + return bytes(value[1:-1], "utf-8").decode("unicode_escape") + except Exception: + return value[1:-1] + return value + + +class Parser: + def __init__(self, source: str): + self.source, self.tokens, self.i = source, lex(source), 0 + + def peek(self) -> Token: return self.tokens[self.i] + def pop(self) -> Token: + token = self.tokens[self.i]; self.i += 1; return token + def accept(self, value: str) -> bool: + if self.peek().value == value: self.i += 1; return True + return False + + def primary(self) -> Any: + token = self.pop() + if token.value == "(": + value = self.expression(); self.accept(")"); return value + if token.kind == "string": return _string(token.value) + if token.kind == "number": return float(token.value) + if token.kind == "ident": + if self.accept("("): + args = [] + while self.peek().kind != "eof" and self.peek().value != ")": + args.append(self.expression()); + if not self.accept(","): break + self.accept(")") + return Call(token.value, args, token.line) + return token.value + if token.value == "[": + values = [] + while self.peek().kind != "eof" and self.peek().value != "]": + values.append(self.expression()); + if not self.accept(","): break + self.accept("]"); return values + if token.value == "{": + obj: dict[str, Any] = {} + while self.peek().kind != "eof" and self.peek().value != "}": + key = _string(self.pop().value); self.accept(":"); obj[str(key)] = self.expression() + if not self.accept(","): break + self.accept("}"); return obj + return token.value + + def expression(self, minimum: int = 0) -> Any: + left = self.primary() + precedence = {"+": 10, "-": 10, "*": 20, "/": 20} + while self.peek().value in precedence and precedence[self.peek().value] >= minimum: + op = self.pop(); right = self.expression(precedence[op.value] + 1) + left = Call("__binary__", [left, op.value, right], op.line) + return left + + def statements(self) -> list[Call]: + found: list[Call] = []; environment: dict[str, Any] = {} + while self.peek().kind != "eof": + if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=": + name = self.pop().value; self.pop() + try: + environment[name] = self.expression() + if isinstance(environment[name], Call) and environment[name].name == "newSketch": + value = environment[name] + value.args = [_resolve(arg, environment) for arg in value.args] + found.append(value) + except Exception: pass + elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(": + value = self.expression() + if isinstance(value, Call): + value.args = [_resolve(arg, environment) for arg in value.args] + found.append(value) + else: self.i += 1 + return found + + +def _resolve(value: Any, environment: dict[str, Any], seen: set[str] | None = None) -> Any: + seen = seen or set() + if isinstance(value, str) and value in environment and value not in seen: + return _resolve(environment[value], environment, seen | {value}) + if isinstance(value, Call): return Call(value.name, [_resolve(arg, environment, seen) for arg in value.args], value.line, value.raw) + if isinstance(value, list): return [_resolve(arg, environment, seen) for arg in value] + if isinstance(value, dict): return {key: _resolve(arg, environment, seen) for key, arg in value.items()} + return value + + +def symbolic_string(value: Any) -> str: + if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "+": + return symbolic_string(value.args[0]) + symbolic_string(value.args[2]) + if isinstance(value, str): return "" if value == "id" else value + return str(value) + + +def _arg_map(call: Call) -> dict[str, Any]: + # Object literals are parsed as dictionaries; FeatureScript operation calls + # conventionally put the definition map in the final argument. + return next((arg for arg in reversed(call.args) if isinstance(arg, dict)), {}) + + +def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: + parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source) + for call in calls: + if call.name == "newSketch": + definition = _arg_map(call) + fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"sketch_{len(model.sketches)}" + sketch_ir = SketchIR(fid, definition.get("sketchPlane"), []) + model.sketches.append(sketch_ir); model.steps.append(sketch_ir) + elif call.name in {"skLineSegment", "skCircle", "skArc", "skEllipse", "skFitSpline", "skPoint"}: + # Attach sketch entities to the most recently declared sketch. + if model.sketches: + args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}" + model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name)) + elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies"}: + fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}" + feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name) + model.features.append(feature_ir); model.steps.append(feature_ir) + return model diff --git a/cadfs_to_cdsl/ir.py b/cadfs_to_cdsl/ir.py new file mode 100644 index 00000000..d4abb10c --- /dev/null +++ b/cadfs_to_cdsl/ir.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Call: + name: str + args: list[Any] = field(default_factory=list) + line: int = 0 + raw: str = "" + + +@dataclass +class FeatureIR: + feature_id: str + operation: str + params: dict[str, Any] + queries: list[Any] = field(default_factory=list) + line_start: int = 0 + line_end: int = 0 + raw_source: str = "" + + +@dataclass +class SketchIR: + feature_id: str + workplane: Any + entities: list[FeatureIR] = field(default_factory=list) + + +@dataclass +class ModelIR: + sample_id: str + features: list[FeatureIR] = field(default_factory=list) + sketches: list[SketchIR] = field(default_factory=list) + steps: list[Any] = field(default_factory=list) + raw_source: str = "" diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py new file mode 100644 index 00000000..30b22f45 --- /dev/null +++ b/cadfs_to_cdsl/lowering.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any +from .featurescript_parser import symbolic_string +from .ir import Call, FeatureIR, ModelIR, SketchIR +from .query_parser import parse_query, walk_calls + + +UNSUPPORTED = {"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive"} +PLANES = { + "Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]}, + "Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]}, + "Right": {"origin_mm": [0., 0., 0.], "x_dir": [0., 1., 0.], "normal": [1., 0., 0.]}, +} + + +@dataclass +class LoweringResult: + cdsl: dict[str, Any] | None + status: str + diagnostics: list[dict[str, Any]] + history: list[dict[str, Any]] + + +def plain(value: Any) -> Any: + if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line} + if isinstance(value, list): return [plain(item) for item in value] + if isinstance(value, dict): return {key: plain(item) for key, item in value.items()} + return value + + +def _bool(value: Any) -> bool: + return value is True or (isinstance(value, str) and value.lower() == "true") + + +def _number(value: Any, units: bool = False) -> float: + if isinstance(value, (float, int)): return float(value) + if isinstance(value, str): + constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8} + if value in constants: return constants[value] + return float(value) + if isinstance(value, Call) and value.name == "__binary__": + left, op, right = value.args; a, b = _number(left, units), _number(right, units) + return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)] + raise ValueError(f"not a constant number: {plain(value)!r}") + + +def _point(value: Any) -> list[float]: + if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*": + scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point] + if isinstance(value, Call) and value.name in {"v", "vector"} and len(value.args) >= 2: + return [_number(value.args[0]), _number(value.args[1])] + if isinstance(value, list) and len(value) >= 2: return [_number(value[0]), _number(value[1])] + raise ValueError(f"not a 2D point: {plain(value)!r}") + + +def _cross(a: list[float], b: list[float]) -> list[float]: + return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] + + +def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"]) + + +def _global(plane: dict[str, Any], point: list[float]) -> list[float]: + y = _y_dir(plane); return [plane["origin_mm"][i] + plane["x_dir"][i]*point[0] + y[i]*point[1] for i in range(3)] + + +def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]: + return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]} + + +def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]: + for call in walk_calls(value): + if call.name in {"makeId", "qCreatedBy"}: + text = " ".join(symbolic_string(arg) for arg in call.args) + for name, plane in PLANES.items(): + if f"{name}.planeOp" in text: return dict(plane) + query = parse_query(value) + frame = feature_frames.get(query.owner_feature or "") + if frame: + return dict(frame["start" if query.is_start is not False else "end"]) + raise ValueError("unsupported or unresolved sketch workplane") + + +def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]: + ax, ay = start; bx, by = mid; cx, cy = end + d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by)) + if abs(d) < 1e-9: raise ValueError("collinear arc points") + ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d + uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d + cross = (mid[0]-start[0])*(end[1]-mid[1])-(mid[1]-start[1])*(end[0]-mid[0]) + return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": cross < 0} + + +def _endpoint(segment: dict[str, Any], end: bool = False) -> tuple[int, int]: + point = segment["end" if end else "start"]; return round(point[0]*1e5), round(point[1]*1e5) + + +def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + circles = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] == "circle"] + edges = [item for item in segments if item["type"] != "circle"]; unused = set(range(len(edges))); contours = []; construction = [] + while unused: + idx = unused.pop(); contour = [edges[idx]]; first = _endpoint(contour[0]); tail = _endpoint(contour[-1], True) + while tail != first: + match = next((j for j in unused if _endpoint(edges[j]) == tail or _endpoint(edges[j], True) == tail), None) + if match is None: + construction.extend(contour); break + unused.remove(match); item = dict(edges[match]) + if _endpoint(item, True) == tail: + item["start"], item["end"] = item["end"], item["start"] + if item["type"] == "arc": item["clockwise"] = not item["clockwise"] + contour.append(item); tail = _endpoint(item, True) + if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour}) + return contours + circles, construction + + +def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = [] + for entity in sketch.entities: + p = entity.params + if entity.operation == "skPoint": entities[entity.feature_id] = {"type": "point", "point": _point(p["position"])}; continue + if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])} + elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)} + elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"])) + else: unsupported.append(entity.operation); continue + (explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item + if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) + if not segments: + profile: dict[str, Any] = {"type": "analytic_contours", "contours": []} + if explicit_construction: profile["construction"] = explicit_construction + return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction: + profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]} + else: + contours, construction = _contours(segments); construction.extend(explicit_construction) + if not contours: + profile = {"type": "analytic_contours", "contours": [], "construction": construction} + return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities + profile = {"type": "analytic_contours", "contours": contours} + if construction: profile["construction"] = construction + return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile}, entities + + +def _queries(value: Any) -> list[Any]: + if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return value.args[0] + return [value] + + +def _source_refs(value: Any) -> list[tuple[str, str]]: + refs = [] + for call in walk_calls(value): + if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: + refs.append((symbolic_string(call.args[0]).split(".", 1)[0], str(call.args[2]))) + return refs + + +def _source_sketch(params: dict[str, Any]) -> str | None: + for key in ("entities", "sheetProfilesArray"): + if key in params: + query = parse_query(params[key]) + if query.source_sketch: return query.source_sketch + return None + + +def _profile_executable(sketch: dict[str, Any]) -> bool: + profile = sketch.get("profile") or {} + if profile.get("type") == "circle": return True + if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3 + return bool(profile.get("contours")) + + +def _default_plane(value: Any) -> dict[str, Any] | None: + for call in walk_calls(value): + text = " ".join(symbolic_string(arg) for arg in call.args) + for name, plane in PLANES.items(): + if f"{name}.planeOp" in text: return dict(plane) + return None + + +def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: + diagnostics: list[dict[str, Any]] = []; history = [] + sketches: list[dict[str, Any]] = []; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {} + feature_frames: dict[str, dict[str, Any]] = {} + features: list[dict[str, Any]] = []; complete = True; previous: list[str] = [] + for step in model.steps: + if isinstance(step, SketchIR): + history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]}) + try: + plane = _plane_from_query(step.workplane, feature_frames) + lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities + except Exception as exc: + diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False + continue + item = step + history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source}) + if item.operation in UNSUPPORTED: + diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue + try: + fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any] + if item.operation == "cPlane": + base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset) + feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"} + feature_frames[item.feature_id] = {"start": plane, "end": plane} + elif item.operation == "extrude": + source = _source_sketch(p) + if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved") + if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile") + depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")) + atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind" + params = {"distance_mm": depth, "reverse": reverse} + if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True) + feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"} + plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)} + elif item.operation == "revolve": + source = _source_sketch(p) + if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved") + if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile") + axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "") + if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved") + plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"]) + direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction] + operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add" + full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0)) + feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"} + elif item.operation in {"fillet", "chamfer"}: + key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = [] + for index, query_value in enumerate(_queries(p.get("entities"))): + query = parse_query(query_value); owner = query.owner_feature + if not owner: raise ValueError("selector owner is unresolved") + refs = _source_refs(query_value) + source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") + geometry: dict[str, Any] = {} + frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"] + if source_entity and cap: + if query.topology_type == "SWEPT_EDGE" and frame: + local_point = source_entity.get("point") if source_entity["type"] == "point" else None + if len(refs) >= 2: + left = (entity_by_sketch.get(refs[0][0]) or {}).get(refs[0][1]); right = (entity_by_sketch.get(refs[1][0]) or {}).get(refs[1][1]) + if left and right and left.get("type") == right.get("type") == "line": + local_point = next((a for a in (left["start"], left["end"]) for b in (right["start"], right["end"]) if math.dist(a, b) <= 1e-5), None) + if local_point is None: raise ValueError("swept edge source intersection is unresolved") + start, end = _global(frame["start"], local_point), _global(frame["end"], local_point) + geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} + elif source_entity["type"] == "circle": + # OCC/build123d commonly splits a closed circular edge into four + # quarter-circle records. Bind all four deterministic arc centres. + radius = source_entity["radius_mm"]; center = source_entity["center"] + for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))): + local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)] + selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}}) + continue + elif source_entity["type"] == "line": + start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"]) + geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} + if not geometry: raise ValueError("selector geometry is unresolved") + selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry}) + params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount} + if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation")) + feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"} + elif item.operation == "hole": + locations = _queries(p.get("locations")); positions = []; host_plane = None + for location in locations: + query = parse_query(location); source = query.source_sketch + entity = (entity_by_sketch.get(source or "") or {}).get(query.source_entity or "") + if not source or source not in sketch_by_source or not entity or entity.get("type") != "point": raise ValueError("hole location is unresolved") + positions.append({"mm": [entity["point"][0], entity["point"][1], 0.0]}); host_plane = sketch_by_source[source]["workplane"] + if not positions or host_plane is None: raise ValueError("hole has no resolved locations") + frame = {**host_plane, "y_dir": _y_dir(host_plane)} + if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]} + style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper() + condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end else "blind" + depth_value = p.get("holeDepth") or p.get("tappedDepth") + if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved") + depth = _number(depth_value, True) if depth_value is not None else 1.0 + hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}} + if "COUNTERSINK" in style.upper(): + hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))} + if "COUNTERBORE" in style.upper(): + hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)} + if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True} + feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"} + elif item.operation == "mirror": + owners = [] + for call in walk_calls(p.get("entities")): + if call.name == "makeQuery" and call.args: + owner = symbolic_string(call.args[0]); + if "F" in owner: + source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0] + if source_id in previous and source_id not in owners: owners.append(source_id) + if not owners: raise ValueError("mirror source features are unresolved") + plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None + if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features): + mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + else: + plane = _default_plane(plane_query) + if plane is None: raise ValueError("mirror plane is not a default or reference plane") + plane_owner = f"{fid}_plane" + features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}) + previous.append(plane_owner) + mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} + feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"} + else: + raise ValueError(f"operation mapping not implemented: {item.operation}") + features.append(feature); previous.append(fid) + except Exception as exc: + diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False + if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history) + cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, + "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})}, + "geometry": {"sketches": sketches}, "features": features} + return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history) diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py new file mode 100644 index 00000000..0aed61a0 --- /dev/null +++ b/cadfs_to_cdsl/pipeline.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import hashlib, multiprocessing, random +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any +from .compare import compare_steps +from .dataset import Sample, scan_dataset +from .featurescript_parser import parse_featurescript +from .lowering import lower_model +from .rebuild import rebuild_candidate +from .reports import generate_reports, read_json, write_json, write_manifest + +PIPELINE_VERSION = "cadfs_to_cdsl.v1" + + +def _fingerprint(sample: Sample) -> str: + value = PIPELINE_VERSION + "|" + "|".join(f"{key}:{value}" for key, value in sorted(sample.hashes.items())) + return hashlib.sha256(value.encode()).hexdigest() + + +def _sample_dir(output: Path, sample_id: str) -> Path: return output / "samples" / sample_id + + +def scan(input_root: Path, output: Path) -> list[dict[str, Any]]: + records = [sample.as_dict() for sample in scan_dataset(input_root)] + write_json(output / "dataset_index.json", {"schema": "cadfs_to_cdsl.dataset_index.v1", "input": str(input_root), "sample_count": len(records), "records": records}) + initial = [{"sample_id": item["sample_id"], "status": "scanned", "diagnostics": item["diagnostics"]} for item in records] + write_manifest(output / "manifest.jsonl", initial) + return records + + +def load_samples(input_root: Path, output: Path) -> list[Sample]: + index = output / "dataset_index.json" + records = read_json(index)["records"] if index.exists() else scan(input_root, output) + return [Sample(**{key: value for key, value in item.items() if key in {"sample_id", "files", "hashes", "metadata", "diagnostics"}}) for item in records] + + +def select_samples(samples: list[Sample], *, sample_ids: list[str] | None = None, offset: int = 0, limit: int | None = None, seed: int | None = None) -> list[Sample]: + if sample_ids: + wanted = set(sample_ids); chosen = [sample for sample in samples if sample.sample_id in wanted] + missing = wanted - {sample.sample_id for sample in chosen} + if missing: raise ValueError("unknown sample ids: " + ", ".join(sorted(missing))) + return chosen + chosen = list(samples) + if seed is not None and limit is not None: + chosen = random.Random(seed).sample(chosen, min(limit, len(chosen))); chosen.sort(key=lambda item: item.sample_id); return chosen + return chosen[offset:None if limit is None else offset + limit] + + +def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[str, Any]: + directory = _sample_dir(output, sample.sample_id); directory.mkdir(parents=True, exist_ok=True) + fingerprint = _fingerprint(sample); status_path = directory / "status.json" + if not force and status_path.exists(): + cached = read_json(status_path) + if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"): + return cached + diagnostics = list(sample.diagnostics) + try: + feature_path = Path(sample.files["featurescript"]) + model = parse_featurescript(feature_path.read_text(encoding="utf-8"), sample.sample_id) + provenance = {"source_featurescript": str(feature_path), **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, "jsonl": sample.metadata} + result = lower_model(model, provenance); diagnostics.extend(result.diagnostics) + write_json(directory / "history.json", result.history); write_json(directory / "diagnostics.json", diagnostics) + if result.cdsl is not None: + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + semantic = validate_semantic_cdsl(result.cdsl); write_json(directory / "candidate.cdsl.json", result.cdsl) + else: semantic = None + status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": result.status, "conversion_status": result.status, "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics)} + except Exception as exc: + missing = isinstance(exc, FileNotFoundError) + diagnostics.append({"code": "source_missing" if missing else "parse_or_lowering_failed", "message": str(exc), "type": type(exc).__name__}) + write_json(directory / "diagnostics.json", diagnostics) + final_status = "source_missing" if missing else "parse_failed" + status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": final_status, "conversion_status": final_status, "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics)} + write_json(status_path, status); return status + + +def _rebuild_worker(candidate: str, step: str, result: str) -> None: + from .rebuild import rebuild_candidate + write_json(Path(result), rebuild_candidate(read_json(Path(candidate)), Path(step))) + + +def _compare_worker(gold: str, rebuilt: str, result: str) -> None: + write_json(Path(result), compare_steps(Path(gold), Path(rebuilt))) + + +def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool: + result_path.unlink(missing_ok=True) + context = multiprocessing.get_context("spawn") + process = context.Process(target=target, args=args) + process.start(); process.join(timeout_seconds) + if process.is_alive(): + process.terminate(); process.join(5) + if process.is_alive(): process.kill(); process.join() + return False + return process.exitcode == 0 and result_path.exists() + + +def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]: + directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json" + status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force) + if status.get("conversion_status") != "converted_complete": return status + rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step" + if not force and rebuild_path.exists() and status.get("rebuild_status"): + if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status + worker_result = directory / "rebuild.worker.json" + completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds) + if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True) + else: + step_path.unlink(missing_ok=True) + result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}} + write_json(rebuild_path, result) + status["rebuild_status"] = result["status"]; status["status"] = result["status"] + write_json(status_path, status); return status + + +def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 60.0) -> dict[str, Any]: + directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json" + status = read_json(status_path) if status_path.exists() else rebuild_one(sample, output, force=force) + if status.get("rebuild_status") != "rebuilt": return status + comparison_path = directory / "comparison.json" + if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status + if force or not comparison_path.exists(): + worker_result = directory / "comparison.worker.json" + completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds) + if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison) + else: + status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status + else: comparison = read_json(comparison_path) + status["comparison_decision"] = comparison["decision"] + accepted = comparison[compare_mode]["passed"] + status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected" + write_json(status_path, status); return status + + +def run_stage(stage: str, samples: list[Sample], output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 30.0, workers: int = 1) -> list[dict[str, Any]]: + def process(sample: Sample) -> dict[str, Any]: + if stage == "convert": record = convert_one(sample, output, force=force) + elif stage == "rebuild": record = rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds) + elif stage == "compare": record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds)) + elif stage == "pipeline": + convert_one(sample, output, force=force); rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds); record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds)) + else: raise ValueError(f"unknown stage {stage!r}") + return record + if workers == 1: records = [process(sample) for sample in samples] + else: + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs") as executor: + records = list(executor.map(process, samples)) + manifest_path = output / "manifest.jsonl" + existing = {} + if manifest_path.exists(): + import json + existing = {item["sample_id"]: item for line in manifest_path.read_text(encoding="utf-8").splitlines() if line.strip() for item in [json.loads(line)]} + existing.update({item["sample_id"]: item for item in records}) + merged = [existing[key] for key in sorted(existing)] + write_manifest(manifest_path, merged); generate_reports(output, merged) + return records diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py new file mode 100644 index 00000000..b14f7899 --- /dev/null +++ b/cadfs_to_cdsl/query_parser.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any +from .featurescript_parser import symbolic_string +from .ir import Call + + +@dataclass +class QueryInfo: + kind: str | None = None + owner_feature: str | None = None + topology_type: str | None = None + source_sketch: str | None = None + source_entity: str | None = None + is_start: bool | None = None + calls: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: return asdict(self) + + +def walk_calls(value: Any): + if isinstance(value, Call): + yield value + for arg in value.args: yield from walk_calls(arg) + elif isinstance(value, list): + for item in value: yield from walk_calls(item) + elif isinstance(value, dict): + for item in value.values(): yield from walk_calls(item) + + +def parse_query(value: Any) -> QueryInfo: + info = QueryInfo() + for call in walk_calls(value): + info.calls.append(call.name) + if call.name in {"makeQuery", "qCreatedBy"} and call.args: + owner = symbolic_string(call.args[0]) + if "F" in owner: + tail = owner[owner.find("F"):].split(".", 1)[0] + info.owner_feature = tail + if call.name == "makeQuery" and len(call.args) > 2: + info.topology_type = str(call.args[1]); info.kind = str(call.args[2]).lower() + definition = next((arg for arg in call.args if isinstance(arg, dict)), {}) + if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true" + elif "isStart" in definition: info.is_start = bool(definition["isStart"]) + if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: + sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0] + info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2]) + if call.name == "qSketchRegion" and call.args: + info.source_sketch = symbolic_string(call.args[0]); info.kind = "face" + return info diff --git a/cadfs_to_cdsl/rebuild.py b/cadfs_to_cdsl/rebuild.py new file mode 100644 index 00000000..ee913283 --- /dev/null +++ b/cadfs_to_cdsl/rebuild.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]: + from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl + analysis = analyze_cdsl(cdsl) + analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible} + if not analysis.runtime_eligible: + return {"status": "runtime_ineligible", "analysis": analysis_dict} + try: + result = rebuild_cdsl(cdsl, output, strict=True) + return {"status": "rebuilt", "analysis": analysis_dict, "result": result} + except Exception as exc: + detail = {"type": type(exc).__name__, "message": str(exc)} + if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions + return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail} diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py new file mode 100644 index 00000000..9761f339 --- /dev/null +++ b/cadfs_to_cdsl/reports.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import csv, json +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + + +def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) + + +def write_manifest(path: Path, records: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n" for item in records), encoding="utf-8") + + +def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, Any]: + statuses = Counter(str(item.get("status") or "unknown") for item in records) + operations: Counter[str] = Counter(); reasons: Counter[str] = Counter(); gaps: dict[str, list[str]] = defaultdict(list) + rows = [] + for item in records: + sample_dir = output / "samples" / str(item["sample_id"]) + diagnostics_path = sample_dir / "diagnostics.json" + if diagnostics_path.exists(): + for diagnostic in read_json(diagnostics_path): + reasons[str(diagnostic.get("code") or "unknown")] += 1 + if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"])) + history_path = sample_dir / "history.json" + if history_path.exists(): + for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1 + comparison_path = sample_dir / "comparison.json" + if comparison_path.exists(): + comparison = read_json(comparison_path); metrics = comparison["raw"]["metrics"] + rows.append({"sample_id": item["sample_id"], "decision": comparison["decision"], "bbox_max_delta_mm": metrics["bbox_max_delta_mm"], "volume_relative_error": metrics["volume_relative_error"], "surface_area_relative_error": metrics["surface_area_relative_error"]}) + summary = {"schema": "cadfs_to_cdsl.summary.v1", "total_models": len(records), "statuses": dict(statuses), "operation_counts": dict(operations), "failure_reasons": dict(reasons), "capability_gap_counts": {key: len(set(value)) for key, value in gaps.items()}} + write_json(output / "summary.json", summary) + gap_payload = {key: {"sample_count": len(set(ids)), "sample_ids": sorted(set(ids))} for key, ids in sorted(gaps.items())}; write_json(output / "capability_gaps.json", gap_payload) + lines = ["# Unsupported CADFS capabilities", ""] + for name, value in gap_payload.items(): lines.extend([f"## {name}", "", f"Affected models: {value['sample_count']}", "", "Sample IDs: " + ", ".join(value["sample_ids"]), ""]) + (output / "unsupported_capabilities.md").write_text("\n".join(lines), encoding="utf-8") + with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows) + return summary diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py new file mode 100644 index 00000000..dd7a8ae6 --- /dev/null +++ b/cadfs_to_cdsl/selector_binding.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import math +from typing import Any + + +def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None: + scores: list[float] = [] + for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"): + if key in expected: + left, right = expected[key], actual.get(key) + if not isinstance(right, (list, tuple)) or len(left) != len(right): return None + delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right))) + scores.append(max(0.0, 1.0 - delta / 0.05)) + for key in ("radius_mm", "plane_offset_mm"): + if key in expected: + try: delta = abs(float(expected[key]) - float(actual[key])) + except Exception: return None + scores.append(max(0.0, 1.0 - delta / 0.05)) + return sum(scores) / len(scores) if scores else 0.0 + + +def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], records: list[dict[str, Any]], *, minimum_score: float = 0.8) -> dict[str, Any]: + candidates = [] + for record in records: + owners = record.get("owner_feature_ids") or [record.get("feature_id")] + if record.get("kind") != kind or owner_feature_id not in owners: continue + score = _score(geometry, record.get("geometry") or {}) + if score is not None and score >= minimum_score: candidates.append((score, record)) + candidates.sort(key=lambda item: (-item[0], str(item[1].get("record_id")))) + if not candidates: raise ValueError("selector_not_found") + if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous") + score, record = candidates[0] + return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry} diff --git a/cadfs_to_cdsl/tests/__init__.py b/cadfs_to_cdsl/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cadfs_to_cdsl/tests/test_integration.py b/cadfs_to_cdsl/tests/test_integration.py new file mode 100644 index 00000000..a100399c --- /dev/null +++ b/cadfs_to_cdsl/tests/test_integration.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import tempfile, unittest +from pathlib import Path +from cadfs_to_cdsl.compare import compare_steps +from cadfs_to_cdsl.featurescript_parser import parse_featurescript +from cadfs_to_cdsl.lowering import lower_model + + +class IntegrationTests(unittest.TestCase): + def test_known_rp_roundtrip_00000173(self): + root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test" + feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step" + if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed") + cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl + from engine.cdsl_engine.runtime import rebuild_cdsl + with tempfile.TemporaryDirectory() as tmp: + rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True) + comparison = compare_steps(gold, rebuilt) + self.assertFalse(comparison["strict"]["passed"]) + self.assertTrue(comparison["rp"]["passed"]) + self.assertEqual(comparison["decision"], "approximate_pass") + + +if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py new file mode 100644 index 00000000..da4b4828 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json, tempfile, unittest +from pathlib import Path +from cadfs_to_cdsl.featurescript_parser import parse_featurescript +from cadfs_to_cdsl.lowering import lower_model +from cadfs_to_cdsl.pipeline import convert_one +from cadfs_to_cdsl.dataset import Sample +from cadfs_to_cdsl.dataset import scan_dataset +from cadfs_to_cdsl.tests.test_parser import SOURCE + + +class LoweringTests(unittest.TestCase): + def test_missing_dataset_fails_explicitly(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing") + + def test_circle_extrude_is_valid_cdsl_11(self): + result = lower_model(parse_featurescript(SOURCE, "00000173"), {}) + self.assertEqual(result.status, "converted_complete") + self.assertEqual(result.cdsl["schema_version"], "1.1.0") + self.assertEqual(result.cdsl["geometry"]["sketches"][0]["profile"]["radius_mm"], 9.53) + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + + def test_unsupported_operation_is_audited_not_invented(self): + source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",') + result = lower_model(parse_featurescript(source, "00000173"), {}) + self.assertEqual(result.status, "deferred_no_executable_feature") + self.assertIsNone(result.cdsl) + self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation") + + def test_conversion_writes_status_and_sidecars(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE) + sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"}) + status = convert_one(sample, root / "out", force=True) + directory = root / "out/samples/00000173" + self.assertEqual(status["conversion_status"], "converted_complete") + for name in ("candidate.cdsl.json", "history.json", "diagnostics.json", "status.json"): + self.assertTrue((directory / name).exists(), name) + self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173") + + +if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py new file mode 100644 index 00000000..a420fd82 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import unittest +from cadfs_to_cdsl.featurescript_lexer import lex +from cadfs_to_cdsl.featurescript_parser import parse_featurescript +from cadfs_to_cdsl.ir import Call +from cadfs_to_cdsl.query_parser import parse_query +from cadfs_to_cdsl.units import length_mm + + +SOURCE = r''' +FeatureScript 1511; +export const f = defineFeature(function(context, id, definition) { + { var Q0; Q0=qCreatedBy(makeId("Top.planeOp"), FACE); + var sketch = newSketch(context, id + "F0", {"sketchPlane":qUnion([Q0])}); + skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch); } + { var Q0; Q0=qSketchRegion(id + "F0", true); + extrude(context, id + "F1", {"entities":qUnion([Q0]), "depth":120 * mm}); } +}); +''' + + +class ParserTests(unittest.TestCase): + def test_lexer_ignores_comments_and_preserves_lines(self): + tokens = lex('// a\nfoo(/*b*/"x")') + self.assertEqual([token.value for token in tokens[:-1]], ["foo", "(", '"x"', ")"]) + self.assertEqual(tokens[0].line, 2) + + def test_nested_feature_script(self): + model = parse_featurescript(SOURCE, "00000173") + self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1"]) + self.assertEqual(model.sketches[0].entities[0].operation, "skCircle") + self.assertIsInstance(model.features[0].params["entities"], Call) + + def test_query_parser(self): + query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}]) + value = parse_query(query) + self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) + self.assertFalse(value.is_start) + + def test_safe_units(self): + self.assertEqual(length_mm("2 * inch"), 50.8) + self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7) + with self.assertRaises(ValueError): length_mm("external.value * mm") + + +if __name__ == "__main__": unittest.main() diff --git a/cadfs_to_cdsl/units.py b/cadfs_to_cdsl/units.py new file mode 100644 index 00000000..66eaad48 --- /dev/null +++ b/cadfs_to_cdsl/units.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import ast +import operator +import re +from typing import Any + +SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0, + "meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4, + "ft": 304.8, "foot": 304.8, "feet": 304.8} +_NUM_UNIT = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?([A-Za-z]+)\s*$") + + +def _eval(node: ast.AST, names: dict[str, float]) -> float: + if isinstance(node, ast.Expression): return _eval(node.body, names) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value) + if isinstance(node, ast.Name) and node.id in names: return names[node.id] + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value = _eval(node.operand, names); return value if isinstance(node.op, ast.UAdd) else -value + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + left, right = _eval(node.left, names), _eval(node.right, names) + return {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}[type(node.op)](left, right) + raise ValueError("expression is not a safe constant") + + +def length_mm(expression: Any, names: dict[str, float] | None = None) -> float: + if not isinstance(expression, str): raise ValueError("missing length expression") + text = expression.strip().replace("millimeter", "mm").replace("inch", "in") + match = _NUM_UNIT.match(text) + if match: + unit = match.group(2).lower() + if unit not in SCALE: raise ValueError(f"unsupported unit {unit!r}") + return float(match.group(1)) * SCALE[unit] + env = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "inch": 25.4, "in": 25.4, **(names or {})} + try: + return _eval(ast.parse(text, mode="eval"), env) + except Exception as exc: + raise ValueError(f"unsupported length expression {expression!r}") from exc diff --git a/docs/README.md b/docs/README.md index 897fc66f..dbe1fb61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,3 +2,5 @@ Architecture decisions, API contracts, engine usage, data formats, and development notes belong here. + +- [CDSL format structure](cdsl-format.md) diff --git a/docs/cdsl-format.md b/docs/cdsl-format.md new file mode 100644 index 00000000..8d7fc494 --- /dev/null +++ b/docs/cdsl-format.md @@ -0,0 +1,71 @@ +# CDSL 格式说明 + +CDSL(`cad.cdsl.llm.v1`)是描述**单个零件建模历史**的 JSON 格式。当前版本为 +`1.1.0`:先定义草图,再按依赖顺序定义基体、切除、孔和修饰等特征。 + +## 基本结构 + +```text +根对象 +|- 身份信息:schema、schema_version、kind、part_id、meta +|- geometry.sketches:二维草图及其三维工作平面 +`- features:按依赖顺序执行的建模特征 +``` + +下面的示例创建一个半径 20 mm、高 8 mm 的圆柱: + +```json +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "cylinder-001", + "meta": {"unit": "mm"}, + "geometry": { + "sketches": [ + { + "id": "sk_base", + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1] + }, + "profile": { + "type": "circle", + "center": [0, 0], + "radius_mm": 20 + } + } + ] + }, + "features": [ + { + "id": "base_add", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "sketch_id": "sk_base", + "params": {"distance_mm": 8}, + "execution_status": "supported" + } + ] +} +``` + +## 字段说明 + +| 部分 | 关键字段 | 含义 | +| --- | --- | --- | +| 根对象 | `part_id`、`meta.unit` | 零件 ID;v1.1 的单位固定为 `mm`。 | +| 草图 | `id`、`workplane`、`profile` | 草图 ID、所在平面和二维轮廓。工作平面使用三维坐标与方向;轮廓可为 `circle`、`polygon` 或 `analytic_contours`。 | +| 特征 | `id`、`atomic_id`、`params` | 特征 ID、操作类型及其尺寸/参数。 | +| 引用 | `sketch_id`、`depends_on` | `sketch_id` 引用草图;`depends_on` 指向必须先完成的特征。 | +| 拓扑选择 | `selectors` 或参数内的选择器 | 对已有面、边、轴或基准面进行孔、圆角、倒角等操作时使用。 | + +常见 `atomic_id`:`extrude_add_blind`(拉伸增加)、`extrude_cut_blind`(拉伸切除)、`revolve_add` / `revolve_cut`(旋转)、`hole_blind`(孔)、`fillet`(圆角)、`chamfer`(倒角)、`pattern_linear` / `pattern_mirror`(阵列)。 + +## 编写规则 + +- 所有长度字段使用 mm;二维点为草图平面坐标 `[u, v]`,三维点/方向为 `[x, y, z]`。 +- 草图和特征 ID 必须唯一。特征必须在其 `depends_on` 所引用的特征之后出现。 +- 拉伸和旋转需要 `sketch_id`;孔、圆角、倒角等操作通常需要选择已有的面或边。 +- `execution_status` 写 `supported` 或 `deferred`;它记录导出状态,实际能否重建仍取决于当前引擎预检。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 69626b82..df9f6b8d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -393,6 +393,15 @@ button:disabled { .theme-button { display: grid; width: 30px; place-items: center; padding: 0; } .config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; } .studio-main { display: flex; min-height: 0; flex: 1; } +.task-documents { border-top: 1px solid var(--ui-border); max-height: 34vh; overflow: auto; background: var(--ui-panel-bg); } +.task-checklist { padding: 8px 12px; border-bottom: 1px solid var(--ui-border); display: grid; gap: 5px; } +.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 8px; font-size: 12px; line-height: 1.35; } +.task-checklist-item > span { color: var(--ui-muted); } +.task-checklist-item.is-pass > span { color: #16784d; } +.task-checklist-item.is-fail > span { color: #b73c32; } +.task-document { border-bottom: 1px solid var(--ui-border); padding: 8px 12px; font-size: 13px; } +.task-document summary { cursor: pointer; font-weight: 600; } +.task-document > :not(summary) { margin-top: 8px; } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } .preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 7d87605e..f61d5554 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -21,6 +21,7 @@ import type { } from "@/lib/cad-types"; import { AgentThread } from "./agent-thread"; import { CadViewerPreview } from "./cad-viewer-preview"; +import { MarkdownDocument } from "./rich-content"; import type { AssistantRuntime } from "@assistant-ui/react"; type LoadState = "loading" | "ready" | "error"; @@ -282,6 +283,7 @@ export function AgentStudio() { onCadError={handleError} onSelectionChange={setViewerSelection} taskRunning={taskRunning} + taskRecord={taskRecord} /> ); @@ -513,6 +515,7 @@ function StudioShell({ onCadError, onSelectionChange, taskRunning, + taskRecord, }: { config: BackendConfig | null; cadResult: CadResult | null; @@ -532,6 +535,7 @@ function StudioShell({ onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; taskRunning: boolean; + taskRecord: TaskRecord | null; }) { const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); @@ -555,7 +559,10 @@ function StudioShell({ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
- +
@@ -564,6 +571,21 @@ function StudioShell({ ); } +function TaskDocuments({ task }: { task: TaskRecord | null }) { + const documents = [ + ["需求文档", task?.requirements_markdown], + ["完成目标", task?.completion_target_markdown], + ["建模计划", task?.modeling_plan_markdown], + ] as const; + if (!documents.some(([, markdown]) => markdown)) return null; + return
+ {task?.checklist_progress?.length ?
+ {task.checklist_progress.map((item) =>
{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}{item.statement}
)} +
: null} + {documents.map(([title, markdown]) => markdown ?
{title}{markdown}
: null)} +
; +} + function StudioLoading() { return (
diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index cbdb435d..59abfde2 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -46,7 +46,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { event: "cad_error", data: { stage: "generation", - tool: "submit_requirements_spec", + tool: "compile_requirements_spec", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }, @@ -54,7 +54,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { assert.equal(chunk?.type, "data-cad-error"); assert.deepEqual("data" in chunk! ? chunk.data : null, { stage: "generation", - tool: "submit_requirements_spec", + tool: "compile_requirements_spec", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }); diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index e9bd54a0..ef330a5d 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -20,7 +20,7 @@ export function backendEventToUiChunk( data: { ...item.data, sequence }, }; } - if (["image_observation", "requirements_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { + if (["image_observation", "requirements_document_ready", "completion_target_ready", "requirements_compiled", "modeling_plan_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { const review = item.data.review && typeof item.data.review === "object" ? item.data.review as Record : null; @@ -44,7 +44,7 @@ export function backendEventToUiChunk( type: "data-cad-progress", id: `event_${eventId}`, data: { step: item.event, label: ({ - image_observation: "参考图片观察", requirements_ready: "需求规格已就绪", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", + image_observation: "参考图片观察", requirements_document_ready: "需求文档已冻结", completion_target_ready: "完成目标已冻结", requirements_compiled: "需求合同已编译", modeling_plan_ready: "建模计划已冻结", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", } as Record)[item.event], status, ...metadata, message: String( item.data.message || item.data.reason || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "") diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index ea648bf5..74be573b 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -121,8 +121,11 @@ export type TaskRecord = { requirements_contract?: Record | null; requirements_contract_path?: string; requirements_markdown?: string | null; + requirements_document_path?: string; completion_target_markdown?: string | null; completion_target_path?: string; + modeling_plan_markdown?: string | null; + modeling_plan_path?: string; completion_result_markdown?: string | null; completion_result_path?: string; claim_summary?: Array<{ @@ -133,6 +136,11 @@ export type TaskRecord = { status: "pass" | "pending" | "fail" | "unavailable" | string; evidence?: Record; }>; + checklist_progress?: Array<{ + requirement_id: string; + statement: string; + status: "pass" | "pending" | "fail" | string; + }>; pending_action?: { action_id: string; working_head: string; intent: string; requirement_ids: string[]; atomic_id: string; expected_change: string; contract_hash: string } | null; action_ledger_summary?: Array>; usage?: { calls: number; prompt_tokens: number; completion_tokens: number; context_chars: number }; diff --git a/json_to_cdsl/README.md b/json_to_cdsl/README.md index f5fb7dc6..531cac98 100644 --- a/json_to_cdsl/README.md +++ b/json_to_cdsl/README.md @@ -47,3 +47,43 @@ the normalized full source filename. Files that normalize to the same part ID receive a deterministic relative-path hash suffix, so every input record maps to a distinct output. When a matching STEP file exists, it is resolved through the same relative subdirectory as its source record. + +## Onshape API samples + +`download_onshape_samples.py` downloads raw Onshape v9 feature-list responses +for a small set of public ABC Part Studios. Create a personal API key in the +Onshape developer settings, then keep the credentials out of the repository: + +```bash +export ONSHAPE_ACCESS_KEY='...' +export ONSHAPE_SECRET_KEY='...' +python json_to_cdsl/download_onshape_samples.py --count 3 +``` + +If the environment variables are absent, the program prompts without echoing +the values. Output is written under +`json_to_cdsl/input/onshape_api_samples//features.json`; this input +directory is ignored by Git. Use `--url ID=URL` for another Part Studio or +`--url-file` to read ABC `objects_*.yml` mappings. + +## Complete Onshape sample + +`download_onshape_complete.py` saves the complete public-API representation +of one Part Studio. It is the appropriate input for building a CDSL converter: +the directory includes the feature tree, sketch definitions and constraints, +FeatureScript representation, parts, body/topology data, mass properties, +tessellations, previews, native Parasolid, STL, and an independently exported +AP242 STEP reference model. + +```bash +export ONSHAPE_ACCESS_KEY='...' +export ONSHAPE_SECRET_KEY='...' +python json_to_cdsl/download_onshape_complete.py --id 00000352 +``` + +The result is written to +`json_to_cdsl/input/onshape_complete/00000352/manifest.json`. Every successful +artifact has a byte count and SHA-256 in that manifest. Failed endpoints are +also recorded, rather than silently omitted. This is all data exposed by the +public API for the selected Part Studio; it is not an internal `.onshape` +document backup, which Onshape does not expose as a download format. diff --git a/json_to_cdsl/download_onshape_complete.py b/json_to_cdsl/download_onshape_complete.py new file mode 100644 index 00000000..99b1843e --- /dev/null +++ b/json_to_cdsl/download_onshape_complete.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""Download every relevant public API artifact for one Onshape Part Studio. + +The output is a self-describing directory for developing and validating an +Onshape-feature-tree-to-CDSL converter. It intentionally collects both the +editable source representation (features, sketches, parameters and queries) +and independent reconstruction targets (STEP, Parasolid, meshes, topology and +mass properties). Credentials are read from environment variables or hidden +terminal prompts and are never written to the output directory. +""" + +from __future__ import annotations + +import argparse +import base64 +import getpass +import hashlib +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Callable + +from download_onshape_samples import PartStudioRef, parse_part_studio_url + + +API_VERSION = "v17" +DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_complete") +DEFAULT_SAMPLE_ID = "00000352" +DEFAULT_SAMPLE_URL = ( + "https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/" + "w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325" +) +JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09" +USER_AGENT = "cdsl-cad-onshape-complete-downloader/1.0" + + +@dataclass +class DownloadResult: + name: str + status: str + path: str | None = None + url: str | None = None + bytes: int | None = None + sha256: str | None = None + http_status: int | None = None + content_type: str | None = None + message: str | None = None + + +class OnshapeClient: + def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None: + self.ref = ref + self.authorization = authorization + self.timeout = timeout + self.context = ssl.create_default_context(cafile=self._ca_bundle()) + + @staticmethod + def _ca_bundle() -> str | None: + try: + import certifi + except ImportError: + return None + return certifi.where() + + def url(self, path: str, query: dict[str, Any] | None = None) -> str: + encoded = urllib.parse.urlencode(query or {}, doseq=True) + suffix = f"?{encoded}" if encoded else "" + return f"https://{self.ref.stack}/api/{API_VERSION}{path}{suffix}" + + def request( + self, + method: str, + path: str, + query: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + accept: str = JSON_ACCEPT, + ) -> tuple[bytes, str | None, str]: + payload = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + self.url(path, query), + data=payload, + method=method, + headers={ + "Accept": accept, + "Authorization": self.authorization, + "Content-Type": JSON_ACCEPT, + "User-Agent": USER_AGENT, + }, + ) + with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response: + return response.read(), response.headers.get_content_type(), response.geturl() + + def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any: + raw, _, _ = self.request("GET", path, query) + return json.loads(raw.decode("utf-8")) + + +def credentials() -> tuple[str, str]: + access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ") + secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ") + if not access_key or not secret_key: + raise ValueError("both Onshape access and secret keys are required") + return access_key, secret_key + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + + +def file_info(path: Path) -> tuple[int, str]: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return path.stat().st_size, digest.hexdigest() + + +def relative_path(path: Path, root: Path) -> str: + return path.relative_to(root).as_posix() + + +def save_json( + client: OnshapeClient, + root: Path, + name: str, + path: str, + filename: str, + query: dict[str, Any] | None = None, +) -> DownloadResult: + try: + value = client.get_json(path, query) + destination = root / filename + destination.parent.mkdir(parents=True, exist_ok=True) + write_json(destination, value) + size, digest = file_info(destination) + return DownloadResult(name, "downloaded", relative_path(destination, root), client.url(path, query), size, digest) + except urllib.error.HTTPError as exc: + return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000]) + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc: + return DownloadResult(name, "error", url=client.url(path, query), message=str(exc)) + + +def save_binary( + client: OnshapeClient, + root: Path, + name: str, + path: str, + filename: str, + query: dict[str, Any] | None = None, + accept: str = "application/octet-stream", +) -> DownloadResult: + try: + payload, content_type, final_url = client.request("GET", path, query, accept=accept) + destination = root / filename + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + size, digest = file_info(destination) + return DownloadResult(name, "downloaded", relative_path(destination, root), final_url, size, digest, content_type=content_type) + except urllib.error.HTTPError as exc: + return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000]) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + return DownloadResult(name, "error", url=client.url(path, query), message=str(exc)) + + +def part_studio_path(ref: PartStudioRef, suffix: str) -> str: + return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}" + + +def collect_sketch_artifacts( + client: OnshapeClient, + root: Path, + sketches: Any, +) -> list[DownloadResult]: + if not isinstance(sketches, list): + return [] + results: list[DownloadResult] = [] + seen_ids: set[str] = set() + for sketch in sketches: + if not isinstance(sketch, dict): + continue + sketch_id = sketch.get("sketchId") or sketch.get("featureId") + if not isinstance(sketch_id, str) or not sketch_id or sketch_id in seen_ids: + continue + seen_ids.add(sketch_id) + quoted_id = urllib.parse.quote(sketch_id, safe="") + base = part_studio_path(client.ref, f"/sketches/{quoted_id}") + results.append( + save_json(client, root, f"sketch/{sketch_id}/bounding_box", f"{base}/boundingboxes", f"sketches/{sketch_id}/bounding_box.json") + ) + results.append( + save_json(client, root, f"sketch/{sketch_id}/tessellation", f"{base}/tessellatedentities", f"sketches/{sketch_id}/tessellated_entities.json") + ) + return results + + +def export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> DownloadResult: + path = part_studio_path(client.ref, "/export/step") + body = { + "destinationName": f"{sample_id}.step", + "grouping": True, + "notifyUser": False, + "storeInDocument": False, + "triggerAutoDownload": False, + "stepUnit": "METER", + "stepVersionString": "AP242", + } + try: + raw, _, _ = client.request("POST", path, body=body) + translation = json.loads(raw.decode("utf-8")) + translation_id = translation.get("id") + if not isinstance(translation_id, str) or not translation_id: + return DownloadResult("step", "error", url=client.url(path), message="STEP export response has no translation id") + request_path = "/translations/" + urllib.parse.quote(translation_id, safe="") + for _ in range(poll_limit): + state = client.get_json(request_path) + request_state = state.get("requestState") if isinstance(state, dict) else None + if request_state == "DONE": + external_ids = state.get("resultExternalDataIds", []) + if not isinstance(external_ids, list) or not external_ids: + return DownloadResult("step", "error", url=client.url(request_path), message="completed STEP export has no external data id") + external_id = external_ids[0] + if not isinstance(external_id, str): + return DownloadResult("step", "error", url=client.url(request_path), message="invalid STEP external data id") + return save_binary( + client, + root, + "step", + f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(external_id, safe='')}", + "model.step", + accept="application/step, application/octet-stream", + ) + if request_state == "FAILED": + return DownloadResult("step", "http_error", url=client.url(request_path), message=str(state.get("failureReason", "translation failed"))) + time.sleep(poll_seconds) + return DownloadResult("step", "error", url=client.url(request_path), message=f"STEP translation did not finish after {poll_limit} polls") + except urllib.error.HTTPError as exc: + return DownloadResult("step", "http_error", url=client.url(path), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000]) + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc: + return DownloadResult("step", "error", url=client.url(path), message=str(exc)) + + +def export_stl(client: OnshapeClient, root: Path) -> DownloadResult: + """Export a Part Studio STL, retrying the two API-supported encodings. + + Some older public documents reject one encoding even though the Part Studio + can otherwise be read. The retry is recorded in the URL and the manifest + retains a useful error if neither server-side export succeeds. + """ + path = part_studio_path(client.ref, "/stl") + attempts = ( + {"mode": "binary", "grouping": "true", "units": "METER"}, + {"mode": "text", "grouping": "true", "units": "METER"}, + {"mode": "binary", "grouping": "false", "units": "METER"}, + ) + failures: list[str] = [] + for query in attempts: + result = save_binary(client, root, "stl", path, "model.stl", query) + if result.status == "downloaded": + return result + detail = result.message or result.status + failures.append(f"{urllib.parse.urlencode(query)}: {detail}") + return DownloadResult("stl", "error", url=client.url(path), message=" | ".join(failures)) + + +def save_shaded_preview(root: Path) -> DownloadResult: + """Extract the API's base64 PNG view as a locally inspectable preview.""" + shaded_path = root / "shaded_views.json" + try: + value = json.loads(shaded_path.read_text(encoding="utf-8")) + images = value.get("images", []) if isinstance(value, dict) else [] + encoded = images[0] if isinstance(images, list) and images else None + if not isinstance(encoded, str): + return DownloadResult("shaded_preview", "error", message="shaded view response contains no PNG") + payload = base64.b64decode(encoded, validate=True) + if not payload.startswith(b"\x89PNG\r\n\x1a\n"): + return DownloadResult("shaded_preview", "error", message="shaded view response is not a PNG") + destination = root / "shaded_preview.png" + destination.write_bytes(payload) + size, digest = file_info(destination) + return DownloadResult("shaded_preview", "downloaded", relative_path(destination, root), "shaded_views.json#images[0]", size, digest, content_type="image/png") + except (OSError, ValueError, json.JSONDecodeError) as exc: + return DownloadResult("shaded_preview", "error", message=str(exc)) + + +def complete_download(ref: PartStudioRef, authorization: str, output: Path, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]: + root = output / ref.sample_id + root.mkdir(parents=True, exist_ok=True) + client = OnshapeClient(ref, authorization, timeout) + request_info = { + **asdict(ref), + "api_version": API_VERSION, + "download_contract": "all relevant data exposed by the public API for this Part Studio, not an internal Onshape document backup", + } + write_json(root / "request.json", request_info) + + common = {"rollbackBarIndex": -1} + resources: list[tuple[str, str, str, dict[str, Any] | None]] = [ + ("document", f"/documents/{ref.did}", "document.json", None), + ("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None), + ("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None), + ("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None), + ("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None), + ("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}), + ("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}), + ("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common), + ("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None), + ("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}), + ("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}), + ("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}), + ("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}), + ("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None), + ("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}), + ("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common), + ("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true", "includeSurfaces": "true", "useAntiAliasing": "true"}), + ] + results = [save_json(client, root, *resource) for resource in resources] + sketches_result = next((item for item in results if item.name == "sketches" and item.status == "downloaded"), None) + if sketches_result and sketches_result.path: + sketches = json.loads((root / sketches_result.path).read_text(encoding="utf-8")) + results.extend(collect_sketch_artifacts(client, root, sketches)) + + results.extend( + [ + save_binary(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"), + export_stl(client, root), + save_binary(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true", "outputFaceAppearances": "true"}, "model/gltf+json, model/gltf-binary, application/octet-stream"), + save_binary(client, root, "thumbnail", f"/thumbnails/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/s/512x512", "thumbnail.png", {"rejectEmpty": "true"}, "image/png, image/*, application/octet-stream"), + ] + ) + results.append(save_shaded_preview(root)) + results.append(export_step(client, root, ref.sample_id, poll_seconds, poll_limit)) + manifest = { + "schema": "onshape.complete_sample.v1", + "source": request_info, + "resource_count": len(results), + "downloaded_count": sum(item.status == "downloaded" for item in results), + "unavailable_count": sum(item.status != "downloaded" for item in results), + "resources": [asdict(item) for item in results], + } + write_json(root / "manifest.json", manifest) + return manifest + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default=DEFAULT_SAMPLE_URL, help="Onshape Part Studio URL") + parser.add_argument("--id", default=DEFAULT_SAMPLE_ID, help="local sample identifier") + parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output root") + parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds") + parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval") + parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.poll_seconds <= 0 or args.poll_limit < 1: + raise ValueError("poll interval must be positive and poll limit must be at least 1") + ref = parse_part_studio_url(args.id, args.url) + access_key, secret_key = credentials() + token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii") + manifest = complete_download(ref, f"Basic {token}", args.out, args.timeout, args.poll_seconds, args.poll_limit) + print(json.dumps({key: manifest[key] for key in ("resource_count", "downloaded_count", "unavailable_count")}, indent=2)) + return 0 if manifest["downloaded_count"] else 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/json_to_cdsl/download_onshape_samples.py b/json_to_cdsl/download_onshape_samples.py new file mode 100644 index 00000000..8d7c0d23 --- /dev/null +++ b/json_to_cdsl/download_onshape_samples.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Download a few raw Onshape feature-list responses for format inspection.""" + +from __future__ import annotations + +import argparse +import base64 +import getpass +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +API_BASE = "https://cad.onshape.com" +DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_api_samples") +DEFAULT_SAMPLES = ( + ( + "00000352", + "https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/" + "w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325", + ), + ( + "00000001", + "https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/" + "w/6e295017d1b34be684565c40/e/8df255ee6705423d8e85234e", + ), + ( + "00000002", + "https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/" + "w/6e295017d1b34be684565c40/e/bb398e4615fe4025b34ea8f0", + ), +) +ONSHAPE_URL_RE = re.compile( + r"^https://(?P[^/]+)/documents/(?P[^/]+)/" + r"(?Pw|v|m)/(?P[^/]+)/e/(?P[^/?#]+)" +) + + +@dataclass(frozen=True) +class PartStudioRef: + sample_id: str + source_url: str + stack: str + did: str + wvm: str + wvmid: str + eid: str + + +def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef: + match = ONSHAPE_URL_RE.match(url.strip()) + if match is None: + raise ValueError(f"not an Onshape Part Studio URL: {url}") + return PartStudioRef(sample_id=sample_id, source_url=url.strip(), **match.groupdict()) + + +def read_url_file(path: Path) -> list[tuple[str, str]]: + """Read either ABC objects YAML lines or plain ' ' lines.""" + records: list[tuple[str, str]] = [] + url_pattern = re.compile(r"https://cad\.onshape\.com/documents/[^'\"\s]+") + id_pattern = re.compile(r"^\s*['\"]?(\d+)['\"]?\s*[:\s]") + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + url_match = url_pattern.search(line) + if url_match is None: + continue + id_match = id_pattern.search(line) + if id_match is None: + raise ValueError(f"{path}:{line_number}: URL has no leading sample ID") + records.append((id_match.group(1).zfill(8), url_match.group(0))) + if not records: + raise ValueError(f"no Onshape Part Studio URLs found in {path}") + return records + + +def credentials() -> tuple[str, str]: + access_key = os.environ.get("ONSHAPE_ACCESS_KEY") + secret_key = os.environ.get("ONSHAPE_SECRET_KEY") + if not access_key: + access_key = getpass.getpass("Onshape access key: ") + if not secret_key: + secret_key = getpass.getpass("Onshape secret key: ") + if not access_key or not secret_key: + raise ValueError("both Onshape access and secret keys are required") + return access_key, secret_key + + +def api_url(ref: PartStudioRef) -> str: + query = urllib.parse.urlencode( + { + "rollbackBarIndex": -1, + "includeGeometryIds": "true", + "noSketchGeometry": "false", + } + ) + return ( + f"https://{ref.stack}/api/v9/partstudios/d/{ref.did}/" + f"{ref.wvm}/{ref.wvmid}/e/{ref.eid}/features?{query}" + ) + + +def fetch_json(url: str, authorization: str, timeout: float) -> Any: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json;charset=UTF-8; qs=0.09", + "Authorization": authorization, + "User-Agent": "cdsl-cad-onshape-sample-downloader/1.0", + }, + ) + context = ssl.create_default_context(cafile=_ca_bundle()) + with urllib.request.urlopen(request, timeout=timeout, context=context) as response: + return json.load(response) + + +def _ca_bundle() -> str | None: + try: + import certifi + except ImportError: + return None + return certifi.where() + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + + +def download_one( + ref: PartStudioRef, + authorization: str, + output_dir: Path, + timeout: float, +) -> dict[str, Any]: + sample_dir = output_dir / ref.sample_id + sample_dir.mkdir(parents=True, exist_ok=True) + request_url = api_url(ref) + metadata = { + **asdict(ref), + "api_version": "v9", + "features_url": request_url, + } + write_json(sample_dir / "request.json", metadata) + try: + payload = fetch_json(request_url, authorization, timeout) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return { + "sample_id": ref.sample_id, + "status": "http_error", + "http_status": exc.code, + "message": body[:1000], + } + except (urllib.error.URLError, TimeoutError) as exc: + return { + "sample_id": ref.sample_id, + "status": "network_error", + "message": str(exc), + } + + output_path = sample_dir / "features.json" + write_json(output_path, payload) + features = payload.get("features", []) if isinstance(payload, dict) else [] + feature_types: dict[str, int] = {} + for feature in features: + if not isinstance(feature, dict): + continue + feature_type = str(feature.get("featureType", "unknown")) + feature_types[feature_type] = feature_types.get(feature_type, 0) + 1 + return { + "sample_id": ref.sample_id, + "status": "downloaded", + "output": str(output_path), + "feature_count": len(features), + "feature_types": feature_types, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--url", + action="append", + default=[], + metavar="ID=URL", + help="Onshape Part Studio URL with a stable local sample ID; repeatable", + ) + parser.add_argument( + "--url-file", + type=Path, + help="ABC objects YAML or plain text file containing ' ' records", + ) + parser.add_argument("--count", type=int, default=3, help="maximum samples to download") + parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output directory") + parser.add_argument("--timeout", type=float, default=60.0, help="request timeout in seconds") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.count < 1: + raise ValueError("--count must be at least 1") + + records: list[tuple[str, str]] = [] + for value in args.url: + if "=" not in value: + raise ValueError("--url must use ID=URL syntax") + records.append(tuple(value.split("=", 1))) + if args.url_file: + records.extend(read_url_file(args.url_file)) + if not records: + records.extend(DEFAULT_SAMPLES) + + refs = [parse_part_studio_url(sample_id, url) for sample_id, url in records[: args.count]] + access_key, secret_key = credentials() + token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii") + authorization = f"Basic {token}" + + args.out.mkdir(parents=True, exist_ok=True) + results = [download_one(ref, authorization, args.out, args.timeout) for ref in refs] + manifest = { + "schema": "onshape.api.samples.v1", + "api_base": API_BASE, + "sample_count": len(results), + "results": results, + } + write_json(args.out / "manifest.json", manifest) + print(json.dumps(manifest, ensure_ascii=True, indent=2)) + return 0 if all(item["status"] == "downloaded" for item in results) else 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/onshape_to_cdsl/README.md b/onshape_to_cdsl/README.md new file mode 100644 index 00000000..f20f8220 --- /dev/null +++ b/onshape_to_cdsl/README.md @@ -0,0 +1,94 @@ +# Onshape to CDSL + +This package produces an auditable CDSL dataset from official public Onshape +Part Studio URL mappings. It never treats a feature tree as sufficient proof: +each accepted record has its complete raw API evidence, original AP242 STEP, +CDSL-only rebuilt STEP, and an independent strict geometry report. + +```text +scan -> select -> download -> convert -> validate -> dataset/.cdsl.json +``` + +Run from the repository root. The only runtime path setup needed is the engine +and this source package; no new packaging tool is introduced. + +```bash +export ONSHAPE_ACCESS_KEY='...' +export ONSHAPE_SECRET_KEY='...' +export PYTHONPATH=backend/engine:onshape_to_cdsl/src + +python -m onshape_to_cdsl scan --url-file /path/to/objects.yml --limit 500 --workers 1 +python -m onshape_to_cdsl select --count 100 --seed 0 +python -m onshape_to_cdsl download +python -m onshape_to_cdsl convert +python -m onshape_to_cdsl validate +``` + +To find more than one URL range yields, scan non-overlapping batches and merge +them before deterministic selection: + +```bash +python -m onshape_to_cdsl scan --url-file objects_0000.yml --offset 0 --limit 500 --out data/scan/00000.json +python -m onshape_to_cdsl scan --url-file objects_0000.yml --offset 500 --limit 500 --out data/scan/00500.json +python -m onshape_to_cdsl merge-scans --scan data/scan/00000.json --scan data/scan/00500.json --out data/scan/merged.json +python -m onshape_to_cdsl select --scan data/scan/merged.json --count 100 --seed 0 +``` + +For hosts that limit foreground process duration, download the deterministic +selection in resumable batches. Existing artifacts are reused by SHA-256: + +```bash +python -m onshape_to_cdsl download --offset 0 --limit 3 +python -m onshape_to_cdsl download --offset 3 --limit 3 +``` + +Or run all stages in sequence: + +```bash +python -m onshape_to_cdsl run --url-file /path/to/objects.yml --scan-limit 500 --scan-workers 1 --count 100 --seed 0 +``` + +`--url-file` accepts `id URL`, `id: URL`, and the same-line ID/URL records in +ABC `objects_*.yml`. URLs are deliberately not hard-coded in the repository. +Credentials are read from `ONSHAPE_ACCESS_KEY`/`ONSHAPE_SECRET_KEY` or hidden +terminal prompts, and are never written to files or logs. + +The official ABC mapping archive is published by NYU as +`abc_objects_00-49.7z` and `abc_objects_50-99.7z`. Each contained +`objects_*.yml` file has 10,000 URLs. Start with `--scan-limit 500`; the +pipeline stratifies those API-inspected candidates and selects 100 complete +downloads. The client honors `429 Retry-After` responses; use one scan worker +unless the API quota explicitly permits higher concurrency. + +All generated data lives in ignored `onshape_to_cdsl/data/`: + +```text +scan/manifest.json feature classifications for all source URLs +selection/manifest.json deterministic, stratified selected candidates +raw// complete public API responses, meshes, STEP and hashes +converted// candidate CDSL, rebuild STEP, conversion/validation reports +dataset/.cdsl.json only strict-validation accepted CDSL +rejected// candidate/rebuild/report evidence for every failure +reconstruction_issues.md generated rejection and engine capability work queue +reconstruction_issues.json machine-readable version of the same register +``` + +The v1 executable converter intentionally supports only solved `newSketch` +geometry composed of lines, arcs and circles plus standard, one-direction, +no-draft `BLIND` extrudes (`NEW`, `ADD`, `REMOVE`). The registry audits all +other feature types. Unsupported standard operations are `deferred`; custom +FeatureScript, imports and external/derived dependencies are +`not_admissible`. Their raw feature trees, FeatureScript payload, B-rep and +STEP remain in `raw/`, but they cannot enter `dataset/`. + +Admission uses a separate comparator, not the engine's preview comparator. +It requires bidirectional B-rep surface sample maximum and P99 distances at +most `0.01 mm`, six-coordinate bounding-box error at most `0.01 mm`, volume +and surface-area relative error at most `1e-5`, and equal solid counts. +Topology counts/types are report-only diagnostics because kernel exports may +split equivalent faces differently. + +`validate` regenerates `reconstruction_issues.md` on every run. It classifies +each failure as a source dependency limitation, converter gap, engine +capability gap, engine execution failure, or strict geometric regression, and +adds engine-specific blockers to an explicit implementation work queue. diff --git a/onshape_to_cdsl/download_steps.py b/onshape_to_cdsl/download_steps.py new file mode 100644 index 00000000..3b64983f --- /dev/null +++ b/onshape_to_cdsl/download_steps.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Download AP242 STEP files for public Onshape Part Studios only.""" + +from __future__ import annotations + +import argparse +import base64 +import getpass +import hashlib +import json +import os +import re +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +API_VERSION = "v17" +DEFAULT_OUTPUT = Path("onshape_to_cdsl/input/raw") +DEFAULT_SAMPLES = ( + ( + "00000001", + "https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/" + "w/6e295017d1b34be684565c40/e/8df255ee6705423d8e85234e", + ), + ( + "00000002", + "https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/" + "w/6e295017d1b34be684565c40/e/bb398e4615fe4025b34ea8f0", + ), +) +ONSHAPE_URL_RE = re.compile( + r"^https://(?P[^/]+)/documents/(?P[^/]+)/" + r"(?Pw|v|m)/(?P[^/]+)/e/(?P[^/?#]+)" +) +JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09" +STEP_ACCEPT = "application/step, application/octet-stream" +USER_AGENT = "cdsl-cad-onshape-step-downloader/1.0" + + +@dataclass(frozen=True) +class PartStudioRef: + sample_id: str + source_url: str + stack: str + did: str + wvm: str + wvmid: str + eid: str + + +@dataclass +class StepResult: + status: str + path: str | None = None + bytes: int | None = None + sha256: str | None = None + translation_id: str | None = None + http_status: int | None = None + message: str | None = None + + +def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef: + match = ONSHAPE_URL_RE.match(url.strip()) + if match is None: + raise ValueError(f"not an Onshape Part Studio URL: {url}") + return PartStudioRef(sample_id=sample_id, source_url=url.strip(), **match.groupdict()) + + +def read_url_file(path: Path) -> list[tuple[str, str]]: + """Read ABC objects YAML lines or plain ' ' records.""" + records: list[tuple[str, str]] = [] + url_pattern = re.compile(r"https://cad\.onshape\.com/documents/[^'\"\s]+") + id_pattern = re.compile(r"^\s*['\"]?([A-Za-z0-9_-]+)['\"]?\s*[:\s]") + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + url_match = url_pattern.search(line) + if url_match is None: + continue + id_match = id_pattern.search(line) + if id_match is None: + raise ValueError(f"{path}:{line_number}: URL has no leading sample ID") + records.append((id_match.group(1), url_match.group(0))) + if not records: + raise ValueError(f"no Onshape Part Studio URLs found in {path}") + return records + + +def credentials() -> tuple[str, str]: + access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ") + secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ") + if not access_key or not secret_key: + raise ValueError("both Onshape access and secret keys are required") + return access_key, secret_key + + +def ca_bundle() -> str | None: + try: + import certifi + except ImportError: + return None + return certifi.where() + + +class OnshapeClient: + def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None: + self.ref = ref + self.authorization = authorization + self.timeout = timeout + self.context = ssl.create_default_context(cafile=ca_bundle()) + + def url(self, path: str) -> str: + return f"https://{self.ref.stack}/api/{API_VERSION}{path}" + + def request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + accept: str = JSON_ACCEPT, + ) -> bytes: + payload = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + self.url(path), + data=payload, + method=method, + headers={ + "Accept": accept, + "Authorization": self.authorization, + "Content-Type": JSON_ACCEPT, + "User-Agent": USER_AGENT, + }, + ) + with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response: + return response.read() + + def get_json(self, path: str) -> Any: + return json.loads(self.request("GET", path).decode("utf-8")) + + +def sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def export_step( + client: OnshapeClient, + root: Path, + *, + poll_seconds: float, + poll_limit: int, +) -> StepResult: + ref = client.ref + export_path = f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/export/step" + body = { + "destinationName": f"{ref.sample_id}.step", + "grouping": True, + "notifyUser": False, + "storeInDocument": False, + "triggerAutoDownload": False, + "stepUnit": "METER", + "stepVersionString": "AP242", + } + try: + translation = json.loads(client.request("POST", export_path, body=body).decode("utf-8")) + translation_id = translation.get("id") + if not isinstance(translation_id, str) or not translation_id: + return StepResult("error", message="STEP export response has no translation id") + translation_path = f"/translations/{urllib.parse.quote(translation_id, safe='')}" + for _ in range(poll_limit): + state = client.get_json(translation_path) + request_state = state.get("requestState") if isinstance(state, dict) else None + if request_state == "DONE": + external_ids = state.get("resultExternalDataIds", []) + if not isinstance(external_ids, list) or not external_ids or not isinstance(external_ids[0], str): + return StepResult("error", translation_id=translation_id, message="completed STEP export has no external data id") + payload = client.request( + "GET", + f"/documents/d/{ref.did}/externaldata/{urllib.parse.quote(external_ids[0], safe='')}", + accept=STEP_ACCEPT, + ) + if not payload.startswith(b"ISO-10303-21"): + return StepResult("invalid_step", translation_id=translation_id, message="download is empty or lacks the ISO-10303-21 STEP header") + destination = root / "model.step" + destination.write_bytes(payload) + return StepResult( + "downloaded", + path=destination.name, + bytes=len(payload), + sha256=sha256(payload), + translation_id=translation_id, + ) + if request_state == "FAILED": + return StepResult("translation_failed", translation_id=translation_id, message=str(state.get("failureReason") or "translation failed")) + time.sleep(poll_seconds) + return StepResult("translation_timeout", translation_id=translation_id, message=f"STEP translation did not finish after {poll_limit} polls") + except urllib.error.HTTPError as exc: + return StepResult("http_error", http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000]) + except (urllib.error.URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc: + return StepResult("error", message=str(exc)) + + +def download_one(ref: PartStudioRef, authorization: str, output: Path, *, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]: + root = output / ref.sample_id + root.mkdir(parents=True, exist_ok=True) + result = export_step(OnshapeClient(ref, authorization, timeout), root, poll_seconds=poll_seconds, poll_limit=poll_limit) + manifest = { + "schema": "onshape.step_sample.v1", + "source": asdict(ref), + "artifact": {"name": "step", **asdict(result)}, + } + (root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + return {"sample_id": ref.sample_id, **asdict(result)} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", action="append", default=[], metavar="ID=URL", help="Onshape Part Studio URL; repeatable") + parser.add_argument("--url-file", type=Path, help="ABC objects YAML or text file containing ID and URL records") + parser.add_argument( + "--count", + type=int, + help="maximum samples to download (defaults to the two built-in samples; custom URLs are appended)", + ) + parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output directory") + parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds") + parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval") + parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if (args.count is not None and args.count < 1) or args.poll_seconds <= 0 or args.poll_limit < 1: + raise ValueError("count and poll limit must be positive; poll interval must be greater than zero") + records: list[tuple[str, str]] = list(DEFAULT_SAMPLES) + for value in args.url: + if "=" not in value: + raise ValueError("--url must use ID=URL syntax") + records.append(tuple(value.split("=", 1))) + if args.url_file: + records.extend(read_url_file(args.url_file)) + limit = args.count if args.count is not None else len(records) + if limit < 1: + raise ValueError("count must be positive") + refs = [parse_part_studio_url(sample_id, url) for sample_id, url in records[:limit]] + access_key, secret_key = credentials() + authorization = "Basic " + base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii") + results = [download_one(ref, authorization, args.out, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit) for ref in refs] + summary = { + "schema": "onshape.step_download_summary.v1", + "requested_count": len(refs), + "downloaded_count": sum(item["status"] == "downloaded" for item in results), + "failed_count": sum(item["status"] != "downloaded" for item in results), + "results": results, + } + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "manifest.json").write_text(json.dumps(summary, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=True, indent=2)) + return 0 if summary["failed_count"] == 0 else 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/__init__.py b/onshape_to_cdsl/src/onshape_to_cdsl/__init__.py new file mode 100644 index 00000000..24165fd1 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/__init__.py @@ -0,0 +1,3 @@ +"""Reproducible Onshape Part Studio to CDSL dataset pipeline.""" + +__version__ = "0.1.0" diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/__main__.py b/onshape_to_cdsl/src/onshape_to_cdsl/__main__.py new file mode 100644 index 00000000..bfdcd0c1 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/cli.py b/onshape_to_cdsl/src/onshape_to_cdsl/cli.py new file mode 100644 index 00000000..faf36537 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/cli.py @@ -0,0 +1,161 @@ +"""Command line interface for the Onshape to CDSL dataset pipeline.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .convert import convert +from .download import download +from .manifests import output_is_current, read_json, sha256_file +from .merge_scans import merge_scans +from .scan import scan +from .select import select +from .validate import validate + + +DEFAULT_DATA = Path("onshape_to_cdsl/data") + + +def _data_path(data: Path, value: str) -> Path: + return data / value + + +def _cached_scan(path: Path, url_file: Path) -> dict | None: + if not path.exists(): + return None + value = read_json(path) + return value if value.get("url_file_sha256") == sha256_file(url_file) else None + + +def _cached_selection(path: Path, scan_path: Path, count: int, seed: int) -> dict | None: + if not path.exists(): + return None + value = read_json(path) + return value if value.get("scan_manifest_sha256") == sha256_file(scan_path) and value.get("requested_count") == count and value.get("seed") == seed else None + + +def _cached_download(raw_root: Path, selection_path: Path) -> dict | None: + path = raw_root / "manifest.json" + if not path.exists(): + return None + value = read_json(path) + if value.get("selection_sha256") != sha256_file(selection_path): + return None + for record in value.get("records", []): + sample = raw_root / str(record.get("sample_id")) + if not (sample / "model.step").is_file(): + return None + return value + + +def _cached_conversion(converted_root: Path) -> dict | None: + path = converted_root / "manifest.json" + if not path.exists(): + return None + value = read_json(path) + for record in value.get("records", []): + if record.get("status") == "converted": + candidate = converted_root / str(record.get("sample_id")) / "candidate.cdsl.json" + if not output_is_current(candidate, record.get("cdsl_sha256")): + return None + return value + + +def _cached_validation(converted_root: Path, dataset_root: Path) -> dict | None: + path = converted_root / "validation-manifest.json" + if not path.exists(): + return None + value = read_json(path) + for record in value.get("records", []): + if record.get("status") == "accepted" and not output_is_current(Path(record["dataset_cdsl"]), record.get("dataset_cdsl_sha256")): + return None + return value + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Resumable Onshape Part Studio to validated CDSL pipeline") + parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA, help="ignored local output root (default: onshape_to_cdsl/data)") + commands = parser.add_subparsers(dest="command", required=True) + scan_parser = commands.add_parser("scan", help="download feature trees only and classify candidates") + scan_parser.add_argument("--url-file", required=True, type=Path) + scan_parser.add_argument("--out", type=Path) + scan_parser.add_argument("--timeout", type=float, default=60.0) + scan_parser.add_argument("--limit", type=int, help="scan only the first N official URLs") + scan_parser.add_argument("--offset", type=int, default=0, help="zero-based source URL offset") + scan_parser.add_argument("--workers", type=int, default=1, help="bounded parallel API requests (default: 1; safest for API quota)") + merge_parser = commands.add_parser("merge-scans", help="combine completed scan batches before selection") + merge_parser.add_argument("--scan", action="append", required=True, type=Path, help="scan manifest; repeatable") + merge_parser.add_argument("--out", required=True, type=Path) + select_parser = commands.add_parser("select", help="deterministically stratify scan results") + select_parser.add_argument("--scan", type=Path) + select_parser.add_argument("--out", type=Path) + select_parser.add_argument("--count", type=int, default=100) + select_parser.add_argument("--seed", type=int, default=0) + download_parser = commands.add_parser("download", help="download all public API evidence and original STEP") + download_parser.add_argument("--selection", type=Path) + download_parser.add_argument("--raw-root", type=Path) + download_parser.add_argument("--timeout", type=float, default=60.0) + download_parser.add_argument("--poll-seconds", type=float, default=3.0) + download_parser.add_argument("--poll-limit", type=int, default=40) + download_parser.add_argument("--offset", type=int, default=0, help="zero-based position in selection") + download_parser.add_argument("--limit", type=int, help="download at most N selected samples") + convert_parser = commands.add_parser("convert", help="convert locally executable histories to CDSL") + convert_parser.add_argument("--raw-root", type=Path) + convert_parser.add_argument("--converted-root", type=Path) + validate_parser = commands.add_parser("validate", help="CDSL-only rebuild and strict STEP admission") + validate_parser.add_argument("--converted-root", type=Path) + validate_parser.add_argument("--dataset-root", type=Path) + validate_parser.add_argument("--rejected-root", type=Path) + run_parser = commands.add_parser("run", help="execute scan, select, download, convert, and validate") + run_parser.add_argument("--url-file", required=True, type=Path) + run_parser.add_argument("--count", type=int, default=100) + run_parser.add_argument("--seed", type=int, default=0) + run_parser.add_argument("--timeout", type=float, default=60.0) + run_parser.add_argument("--poll-seconds", type=float, default=3.0) + run_parser.add_argument("--poll-limit", type=int, default=40) + run_parser.add_argument("--scan-limit", type=int, default=500, help="number of official URLs to scan before stratifying (default: 500)") + run_parser.add_argument("--scan-workers", type=int, default=1, help="bounded parallel scan requests (default: 1; safest for API quota)") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + data = args.data_root + scan_path, selection_path, raw_root, converted_root = _data_path(data, "scan/manifest.json"), _data_path(data, "selection/manifest.json"), _data_path(data, "raw"), _data_path(data, "converted") + if args.command == "scan": + result = scan(args.url_file, args.out or scan_path, timeout=args.timeout, limit=args.limit, offset=args.offset, workers=args.workers) + elif args.command == "merge-scans": + result = merge_scans(args.scan, args.out) + elif args.command == "select": + if args.count < 1: + raise ValueError("--count must be positive") + result = select(args.scan or scan_path, args.out or selection_path, count=args.count, seed=args.seed) + elif args.command == "download": + result = download(args.selection or selection_path, args.raw_root or raw_root, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit, offset=args.offset, limit=args.limit) + elif args.command == "convert": + result = convert(args.raw_root or raw_root, args.converted_root or converted_root) + elif args.command == "validate": + result = validate(args.converted_root or converted_root, args.dataset_root or _data_path(data, "dataset"), args.rejected_root or _data_path(data, "rejected")) + else: + # A stage is reused only when its manifest proves that the material it + # consumed/produced still has the recorded hash. This keeps `run` + # restartable after an interrupted 100-sample network acquisition. + cached_scan = _cached_scan(scan_path, args.url_file) + if cached_scan and (cached_scan.get("scan_limit") != args.scan_limit or cached_scan.get("scan_workers") != args.scan_workers): + cached_scan = None + result = {"scan": cached_scan or scan(args.url_file, scan_path, timeout=args.timeout, limit=args.scan_limit, workers=args.scan_workers)} + result["selection"] = _cached_selection(selection_path, scan_path, args.count, args.seed) or select(scan_path, selection_path, count=args.count, seed=args.seed) + result["download"] = _cached_download(raw_root, selection_path) or download(selection_path, raw_root, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit) + result["convert"] = _cached_conversion(converted_root) or convert(raw_root, converted_root) + dataset_root = _data_path(data, "dataset") + result["validate"] = _cached_validation(converted_root, dataset_root) or validate(converted_root, dataset_root, _data_path(data, "rejected")) + # Detailed reports remain on disk. Do not flood a terminal with runtime + # topology evidence for every selected Part Studio. + if args.command == "run": + printable = {stage: {key: value for key, value in report.items() if key in {"schema", "sample_count", "selected_count", "converted_count", "accepted_count", "rejected_count"}} for stage, report in result.items()} + else: + printable = {key: value for key, value in result.items() if key in {"schema", "sample_count", "selected_count", "converted_count", "accepted_count", "rejected_count"}} + print(json.dumps(printable, ensure_ascii=True, indent=2, sort_keys=True)) + return 0 diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/compare.py b/onshape_to_cdsl/src/onshape_to_cdsl/compare.py new file mode 100644 index 00000000..f489e60e --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/compare.py @@ -0,0 +1,106 @@ +"""Independent strict STEP comparator used for dataset admission only.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any, Iterable + + +TOLERANCE_MM = 0.01 +RELATIVE_TOLERANCE = 1e-5 + + +def _point(value: Any) -> tuple[float, float, float]: + return (float(value.X), float(value.Y), float(value.Z)) + + +def _surface_points(shape: Any, tolerance_mm: float) -> list[tuple[float, float, float]]: + """Deterministically sample vertices and triangle centroids on every face.""" + points: dict[tuple[int, int, int], tuple[float, float, float]] = {} + def add(point: tuple[float, float, float]) -> None: + points.setdefault(tuple(round(value / 1e-8) for value in point), point) + for face in shape.faces(): + try: + vertices, triangles = face.tessellate(tolerance_mm) + rendered = [_point(vertex) for vertex in vertices] + for vertex in rendered: + add(vertex) + for triangle in triangles: + indices = list(triangle) + if len(indices) >= 3: + first, second, third = (rendered[int(index)] for index in indices[:3]) + add(tuple((first[axis] + second[axis] + third[axis]) / 3.0 for axis in range(3))) + except Exception: + # A malformed individual face should not make the comparison pass. + for vertex in face.vertices(): + add(_point(vertex)) + if not points: + raise ValueError("STEP contains no sampleable faces") + return list(points.values()) + + +def _distances(points: Iterable[tuple[float, float, float]], target: Any) -> list[float]: + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex + from OCP.BRepExtrema import BRepExtrema_DistShapeShape + from OCP.gp import gp_Pnt + values: list[float] = [] + for x, y, z in points: + vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex() + distance = BRepExtrema_DistShapeShape() + distance.LoadS1(vertex) + distance.LoadS2(target.wrapped) + distance.Perform() + if not distance.IsDone() or distance.NbSolution() < 1: + raise ValueError("OCC point-to-B-rep distance computation failed") + values.append(float(distance.Value())) + return values + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return math.inf + ordered = sorted(values) + index = min(len(ordered) - 1, math.ceil(percentile * len(ordered)) - 1) + return ordered[index] + + +def _bbox(shape: Any) -> list[float]: + box = shape.bounding_box() + return [float(box.min.X), float(box.min.Y), float(box.min.Z), float(box.max.X), float(box.max.Y), float(box.max.Z)] + + +def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm: float = 0.05) -> dict[str, Any]: + """Compare source and rebuilt B-reps without the engine's permissive rules.""" + from build123d import import_step + gold, rebuilt = import_step(str(gold_step)), import_step(str(rebuilt_step)) + gold_samples = _surface_points(gold, surface_tolerance_mm) + rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm) + forward, reverse = _distances(gold_samples, rebuilt), _distances(rebuilt_samples, gold) + gold_box, rebuilt_box = _bbox(gold), _bbox(rebuilt) + bbox_delta = max(abs(a - b) for a, b in zip(gold_box, rebuilt_box)) + volume_error = abs(float(gold.volume) - float(rebuilt.volume)) / max(abs(float(gold.volume)), 1e-12) + area_error = abs(float(gold.area) - float(rebuilt.area)) / max(abs(float(gold.area)), 1e-12) + forward_max, reverse_max = max(forward), max(reverse) + forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99) + reasons = [] + if max(forward_max, reverse_max) > TOLERANCE_MM: + reasons.append("surface_max_exceeds_0.01mm") + if max(forward_p99, reverse_p99) > TOLERANCE_MM: + reasons.append("surface_p99_exceeds_0.01mm") + if bbox_delta > TOLERANCE_MM: + reasons.append("bbox_exceeds_0.01mm") + if volume_error > RELATIVE_TOLERANCE: + reasons.append("volume_relative_error_exceeds_1e-5") + if area_error > RELATIVE_TOLERANCE: + reasons.append("surface_area_relative_error_exceeds_1e-5") + gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids()) + if gold_solids != rebuilt_solids: + reasons.append("solid_count_mismatch") + return { + "schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm, + "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": not reasons, "failure_reasons": reasons, + "surface": {"gold_to_rebuilt": {"sample_count": len(forward), "max_mm": forward_max, "p95_mm": _percentile(forward, .95), "p99_mm": forward_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in forward)}, "rebuilt_to_gold": {"sample_count": len(reverse), "max_mm": reverse_max, "p95_mm": _percentile(reverse, .95), "p99_mm": reverse_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in reverse)}}, + "metrics": {"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta, "gold_volume_mm3": float(gold.volume), "rebuilt_volume_mm3": float(rebuilt.volume), "volume_relative_error": volume_error, "gold_surface_area_mm2": float(gold.area), "rebuilt_surface_area_mm2": float(rebuilt.area), "surface_area_relative_error": area_error, "gold_solid_count": gold_solids, "rebuilt_solid_count": rebuilt_solids}, + "diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())}, + } diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/convert.py b/onshape_to_cdsl/src/onshape_to_cdsl/convert.py new file mode 100644 index 00000000..e2aa24ae --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/convert.py @@ -0,0 +1,112 @@ +"""Convert fully-audited standard feature histories into self-contained CDSL.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .manifests import read_json, sha256_file, write_json +from .registry import rule_for +from .sketches import sketch_to_cdsl +from .units import length_mm + + +_SKETCH_QUERY = re.compile(r'qSketchRegion\(id\+"(?P[^"]+)"') + + +def _parameters(feature: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(item.get("parameterId")): item for item in feature.get("parameters") or [] if isinstance(item, dict) and item.get("parameterId")} + + +def _value(parameters: dict[str, dict[str, Any]], name: str) -> Any: + return (parameters.get(name) or {}).get("value") + + +def _sketch_id(parameters: dict[str, dict[str, Any]]) -> str: + queries = (parameters.get("entities") or {}).get("queries") or [] + matches = [match.group("id") for query in queries if isinstance(query, dict) for match in _SKETCH_QUERY.finditer(str(query.get("queryString") or ""))] + if len(set(matches)) != 1: + raise ValueError("extrude profile query does not uniquely name one sketch") + return matches[0] + + +def _extrude(feature: dict[str, Any], previous: list[str]) -> dict[str, Any]: + parameters = _parameters(feature) + end_bound = _value(parameters, "endBound") + operation = _value(parameters, "operationType") + if end_bound != "BLIND": + raise ValueError(f"only BLIND extrudes are executable, got {end_bound!r}") + if _value(parameters, "hasDraft") is True or _value(parameters, "hasSecondDirection") is True: + raise ValueError("draft and two-direction extrudes are not executable in v1") + atomic = "extrude_cut_blind" if operation in {"REMOVE", "CUT"} else "extrude_add_blind" if operation in {"NEW", "ADD"} else None + if atomic is None: + raise ValueError(f"unsupported extrude operation {operation!r}") + expression = (parameters.get("depth") or {}).get("expression") + return {"id": "f_" + str(feature["featureId"]).replace("-", "_"), "name": str(feature.get("name") or feature["featureId"]), "atomic_id": atomic, "depends_on": list(previous[-1:]), "sketch_id": _sketch_id(parameters), "params": {"distance_mm": length_mm(expression), "reverse": bool(_value(parameters, "oppositeDirection"))}, "execution_status": "supported"} + + +def convert_one(raw_dir: Path, converted_root: Path) -> dict[str, Any]: + sample_id = raw_dir.name + output = converted_root / sample_id + output.mkdir(parents=True, exist_ok=True) + diagnostics: list[dict[str, Any]] = [] + try: + features_payload = read_json(raw_dir / "features.json") + sketches_payload = read_json(raw_dir / "sketches.json") + features = features_payload.get("features") or [] + solved = {str(item.get("featureId")): item for item in sketches_payload.get("sketches") or [] if isinstance(item, dict) and item.get("featureId")} + cdsl_sketches: dict[str, dict[str, Any]] = {} + cdsl_features: list[dict[str, Any]] = [] + history: list[str] = [] + admissible = True + for index, feature in enumerate(features): + if not isinstance(feature, dict): + continue + feature_type = str(feature.get("featureType") or "unknown") + rule = rule_for(feature_type) + feature_id = str(feature.get("featureId") or f"index_{index}") + if feature_type == "newSketch": + if feature_id not in solved: + diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": "solved sketch payload missing"}) + admissible = False + continue + try: + cdsl_sketches[feature_id] = sketch_to_cdsl(solved[feature_id]) + except ValueError as exc: + diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": str(exc)}) + admissible = False + continue + if feature_type == "extrude": + try: + converted = _extrude(feature, history) + if converted["sketch_id"] not in cdsl_sketches: + raise ValueError("referenced sketch is missing or not executable") + cdsl_features.append(converted) + history.append(converted["id"]) + except ValueError as exc: + diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": str(exc)}) + admissible = False + continue + diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": rule.status, "reason": rule.reason}) + if rule.status != "supported": + admissible = False + provenance = {name: sha256_file(raw_dir / name) for name in ("features.json", "sketches.json", "model.step") if (raw_dir / name).exists()} + result: dict[str, Any] = {"schema": "onshape_to_cdsl.conversion.v1", "sample_id": sample_id, "raw_dir": str(raw_dir), "source_sha256": provenance, "diagnostics": diagnostics, "status": "converted" if admissible and cdsl_features else "rejected"} + if result["status"] == "converted": + cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.0.0", "kind": "part", "part_id": sample_id, "meta": {"source": "onshape", "provenance": provenance}, "geometry": {"sketches": list(cdsl_sketches.values())}, "features": cdsl_features} + write_json(output / "candidate.cdsl.json", cdsl) + result["cdsl_sha256"] = sha256_file(output / "candidate.cdsl.json") + write_json(output / "conversion.json", result) + return result + except Exception as exc: + result = {"schema": "onshape_to_cdsl.conversion.v1", "sample_id": sample_id, "raw_dir": str(raw_dir), "status": "failed", "failure_reason": f"{type(exc).__name__}: {exc}"} + write_json(output / "conversion.json", result) + return result + + +def convert(raw_root: Path, converted_root: Path) -> dict[str, Any]: + records = [convert_one(path, converted_root) for path in sorted(raw_root.iterdir()) if path.is_dir() and (path / "manifest.json").exists()] + manifest = {"schema": "onshape_to_cdsl.convert.v1", "raw_root": str(raw_root), "sample_count": len(records), "converted_count": sum(record["status"] == "converted" for record in records), "records": records} + write_json(converted_root / "manifest.json", manifest) + return manifest diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/download.py b/onshape_to_cdsl/src/onshape_to_cdsl/download.py new file mode 100644 index 00000000..89cafaf5 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/download.py @@ -0,0 +1,167 @@ +"""Complete Part Studio acquisition with per-artifact hashes and safe resume.""" + +from __future__ import annotations + +import base64 +from concurrent.futures import ThreadPoolExecutor +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import time +import urllib.error +import urllib.parse +from typing import Any + +from .manifests import output_is_current, sha256_file, write_json +from .onshape_api import JSON_ACCEPT, OnshapeClient, authorization_from_environment, part_studio_path +from .source_urls import PartStudioRef + + +@dataclass +class Artifact: + name: str + status: str + path: str | None = None + sha256: str | None = None + bytes: int | None = None + url: str | None = None + http_status: int | None = None + message: str | None = None + + +def _save(client: OnshapeClient, root: Path, name: str, api_path: str, filename: str, query: dict[str, Any] | None = None, accept: str = JSON_ACCEPT) -> Artifact: + target = root / filename + if target.exists(): + return Artifact(name, "reused", filename, sha256_file(target), target.stat().st_size) + try: + payload, url = client.request("GET", api_path, query=query, accept=accept) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload) + return Artifact(name, "downloaded", filename, sha256_file(target), len(payload), url) + except urllib.error.HTTPError as exc: + return Artifact(name, "http_error", url=client.url(api_path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000]) + except Exception as exc: + return Artifact(name, "error", url=client.url(api_path, query), message=f"{type(exc).__name__}: {exc}") + + +def _export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> Artifact: + target = root / "model.step" + if target.exists() and target.read_bytes()[:12] == b"ISO-10303-21": + return Artifact("step", "reused", "model.step", sha256_file(target), target.stat().st_size) + path = part_studio_path(client.ref, "/export/step") + body = {"destinationName": f"{sample_id}.step", "grouping": True, "notifyUser": False, "storeInDocument": False, "triggerAutoDownload": False, "stepUnit": "METER", "stepVersionString": "AP242"} + try: + # STEP generation is asynchronous and can outlive a short-lived CLI + # process. Keep only the opaque server job id locally so a resumed + # download polls the existing export instead of creating duplicates. + state_file = root / ".step-translation.json" + try: + translation_id = json.loads(state_file.read_text(encoding="utf-8")).get("translation_id") + except (OSError, json.JSONDecodeError): + translation_id = None + if not isinstance(translation_id, str): + response, _ = client.request("POST", path, body=body) + translation_id = json.loads(response.decode("utf-8")).get("id") + if isinstance(translation_id, str): + write_json(state_file, {"translation_id": translation_id}) + if not isinstance(translation_id, str): + return Artifact("step", "error", message="STEP export response has no translation id") + translation_path = "/translations/" + urllib.parse.quote(translation_id, safe="") + for _ in range(poll_limit): + state = client.get_json(translation_path) + if state.get("requestState") == "DONE": + ids = state.get("resultExternalDataIds") or [] + if not ids or not isinstance(ids[0], str): + return Artifact("step", "error", message="STEP translation has no external data id") + artifact = _save(client, root, "step", f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(ids[0], safe='')}", "model.step", accept="application/step, application/octet-stream") + if artifact.status in {"downloaded", "reused"}: + state_file.unlink(missing_ok=True) + return artifact + if state.get("requestState") == "FAILED": + state_file.unlink(missing_ok=True) + return Artifact("step", "translation_failed", message=str(state.get("failureReason") or "translation failed")) + time.sleep(poll_seconds) + return Artifact("step", "translation_timeout", message=f"not complete after {poll_limit} polls") + except Exception as exc: + return Artifact("step", "error", message=f"{type(exc).__name__}: {exc}") + + +def _sketch_artifacts(client: OnshapeClient, root: Path) -> list[Artifact]: + sketches_path = root / "sketches.json" + if not sketches_path.exists(): + return [] + try: + sketches = json.loads(sketches_path.read_text(encoding="utf-8")).get("sketches", []) + except (OSError, json.JSONDecodeError): + return [] + results: list[Artifact] = [] + for sketch in sketches if isinstance(sketches, list) else []: + feature_id = sketch.get("featureId") if isinstance(sketch, dict) else None + if not isinstance(feature_id, str): + continue + base = part_studio_path(client.ref, f"/sketches/{urllib.parse.quote(feature_id, safe='')}") + results.append(_save(client, root, f"sketch/{feature_id}/bounding_box", base + "/boundingboxes", f"sketches/{feature_id}/bounding_box.json")) + results.append(_save(client, root, f"sketch/{feature_id}/tessellation", base + "/tessellatedentities", f"sketches/{feature_id}/tessellated_entities.json")) + return results + + +def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]: + root = raw_root / ref.sample_id + root.mkdir(parents=True, exist_ok=True) + client = OnshapeClient(ref, authorization, timeout) + common = {"rollbackBarIndex": -1} + artifacts: list[Artifact] = [] + resources = [ + ("document", f"/documents/{ref.did}", "document.json", None), ("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None), + ("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None), ("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None), + ("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None), + ("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}), + ("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}), + ("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common), + ("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None), + ("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}), + ("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}), + ("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}), + ("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}), + ("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None), + ("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}), + ("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common), + ("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true"}), + ] + # These document and Part Studio evidence endpoints are independent. A + # bounded fan-out keeps an interactive acquisition from being cut off + # before it can reach the asynchronous STEP export, while preserving the + # same per-artifact resume and error records. + worker_count = max(1, min(16, int(os.environ.get("ONSHAPE_DOWNLOAD_WORKERS", "4")))) + with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="onshape-download") as executor: + artifacts.extend(executor.map(lambda item: _save(client, root, *item), resources)) + artifacts.extend(_sketch_artifacts(client, root)) + artifacts.extend([ + _save(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"), + _save(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true"}, "model/gltf+json, application/octet-stream"), + _export_step(client, root, ref.sample_id, poll_seconds, poll_limit), + ]) + manifest = {"schema": "onshape_to_cdsl.raw_sample.v1", "source": ref.as_dict(), "resource_count": len(artifacts), "downloaded_count": sum(item.status in {"downloaded", "reused"} for item in artifacts), "unavailable_count": sum(item.status not in {"downloaded", "reused"} for item in artifacts), "resources": [asdict(item) for item in artifacts]} + write_json(root / "manifest.json", manifest) + return manifest + + +def download(selection: Path, raw_root: Path, *, timeout: float = 60.0, poll_seconds: float = 3.0, poll_limit: int = 40, offset: int = 0, limit: int | None = None) -> dict[str, Any]: + from .manifests import read_json + if offset < 0 or limit is not None and limit < 1: + raise ValueError("download offset must be non-negative and limit must be positive") + authorization = authorization_from_environment() + records = [] + selected = list(read_json(selection).get("records", []))[offset: None if limit is None else offset + limit] + for item in selected: + ref = PartStudioRef(**item["source"]) + manifest = download_one(ref, authorization, raw_root, timeout=timeout, poll_seconds=poll_seconds, poll_limit=poll_limit) + records.append({"sample_id": ref.sample_id, "status": "downloaded" if (raw_root / ref.sample_id / "model.step").exists() else "incomplete", "manifest": str(raw_root / ref.sample_id / "manifest.json"), "resource_count": manifest["resource_count"]}) + summary = {"schema": "onshape_to_cdsl.download.v1", "selection": str(selection), "selection_sha256": sha256_file(selection), "offset": offset, "limit": limit, "sample_count": len(records), "records": records} + # A batch manifest remains useful evidence even when an interactive host + # enforces a short process lifetime. The next batch reuses all artifacts. + write_json(raw_root / f"manifest-{offset:05d}.json", summary) + if offset == 0 and limit is None: + write_json(raw_root / "manifest.json", summary) + return summary diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/issues.py b/onshape_to_cdsl/src/onshape_to_cdsl/issues.py new file mode 100644 index 00000000..93cf4518 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/issues.py @@ -0,0 +1,112 @@ +"""Generate a durable reconstruction issue register from pipeline evidence.""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +from .manifests import read_json, write_json + + +_ENGINE_FEATURES = { + "revolve", "hole", "fillet", "chamfer", "linearPattern", "circularPattern", "mirror", "draft", + "loft", "sweep", "shell", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "rib", + "booleanBodies", "sheetMetal", +} + + +def _issue(sample_id: str, category: str, subsystem: str, detail: str, *, feature_id: str | None = None, feature_type: str | None = None) -> dict[str, str]: + result = {"sample_id": sample_id, "category": category, "subsystem": subsystem, "detail": detail} + if feature_id: + result["feature_id"] = feature_id + if feature_type: + result["feature_type"] = feature_type + return result + + +def _conversion_issues(sample_id: str, conversion: dict[str, Any]) -> list[dict[str, str]]: + issues: list[dict[str, str]] = [] + for diagnostic in conversion.get("diagnostics") or []: + if not isinstance(diagnostic, dict): + continue + feature_type = str(diagnostic.get("feature_type") or "unknown") + detail = str(diagnostic.get("reason") or "no diagnostic reason") + status = diagnostic.get("status") + if status == "not_admissible": + category, subsystem = "source_not_admissible", "source_dependency" + elif feature_type in _ENGINE_FEATURES: + category, subsystem = "engine_capability_gap", "cdsl_engine" + else: + category, subsystem = "converter_gap", "onshape_converter" + issues.append(_issue(sample_id, category, subsystem, detail, feature_id=str(diagnostic.get("feature_id") or ""), feature_type=feature_type)) + if conversion.get("status") == "failed": + issues.append(_issue(sample_id, "converter_failure", "onshape_converter", str(conversion.get("failure_reason") or "unknown conversion failure"))) + return issues + + +def _validation_issues(sample_id: str, validation: dict[str, Any]) -> list[dict[str, str]]: + if validation.get("status") != "rejected": + return [] + comparison = validation.get("comparison") + if isinstance(comparison, dict): + reasons = comparison.get("failure_reasons") or ["strict_comparison_failed"] + return [_issue(sample_id, "geometric_regression", "strict_step_comparator", str(reason)) for reason in reasons] + reason = str(validation.get("failure_reason") or "unknown validation failure") + if reason == "conversion_not_executable": + return [] # The detailed conversion diagnostics above are the source of truth. + return [_issue(sample_id, "engine_execution_failure", "cdsl_engine", reason)] + + +def _markdown(issues: list[dict[str, str]], summary: dict[str, int]) -> str: + lines = [ + "# Reconstruction Issue Register", + "", + "This file is generated by `onshape_to_cdsl validate`. It records rejected samples and separates source limitations, converter gaps, engine capability gaps, engine execution failures, and strict geometric regressions.", + "", + "## Summary", + "", + "| Category | Count |", + "| --- | ---: |", + ] + lines.extend(f"| {category} | {count} |" for category, count in sorted(summary.items())) + if not issues: + lines.extend(["", "No reconstruction issues were recorded.", ""]) + return "\n".join(lines) + lines.extend(["", "## Issues", "", "| Sample | Category | Subsystem | Feature | Detail |", "| --- | --- | --- | --- | --- |"]) + for issue in issues: + feature = issue.get("feature_type") or issue.get("feature_id") or "-" + detail = issue["detail"].replace("|", "\\|").replace("\n", " ") + lines.append(f"| {issue['sample_id']} | {issue['category']} | {issue['subsystem']} | {feature} | {detail} |") + lines.extend(["", "## Engine Work Queue", ""]) + grouped: dict[str, set[str]] = defaultdict(set) + for issue in issues: + if issue["category"] in {"engine_capability_gap", "engine_execution_failure"}: + grouped[issue["category"]].add(issue["detail"]) + if not grouped: + lines.append("No engine-specific blockers were observed in this run.") + else: + for category, details in sorted(grouped.items()): + lines.append(f"### {category}") + lines.append("") + lines.extend(f"- {detail}" for detail in sorted(details)) + lines.append("") + return "\n".join(lines) + + +def write_issue_register(converted_root: Path, validation_records: list[dict[str, Any]]) -> dict[str, Any]: + """Persist both machine-readable and Markdown summaries under `data/`.""" + issues: list[dict[str, str]] = [] + for validation in validation_records: + sample_id = str(validation["sample_id"]) + conversion_path = converted_root / sample_id / "conversion.json" + if conversion_path.exists(): + issues.extend(_conversion_issues(sample_id, read_json(conversion_path))) + issues.extend(_validation_issues(sample_id, validation)) + issues.sort(key=lambda issue: (issue["category"], issue["sample_id"], issue.get("feature_type", ""), issue["detail"])) + summary = dict(sorted(Counter(issue["category"] for issue in issues).items())) + register = {"schema": "onshape_to_cdsl.reconstruction_issues.v1", "sample_count": len(validation_records), "issue_count": len(issues), "summary": summary, "issues": issues} + data_root = converted_root.parent + write_json(data_root / "reconstruction_issues.json", register) + (data_root / "reconstruction_issues.md").write_text(_markdown(issues, summary), encoding="utf-8") + return register diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/manifests.py b/onshape_to_cdsl/src/onshape_to_cdsl/manifests.py new file mode 100644 index 00000000..fac94648 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/manifests.py @@ -0,0 +1,42 @@ +"""Small, atomic JSON helpers used by every resumable pipeline stage.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from typing import Any + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def write_json(path: Path, value: Any) -> None: + """Atomically write JSON so interrupted stages never leave a valid-looking file.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary, path) + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def output_is_current(path: Path, expected_sha256: str | None = None) -> bool: + return path.is_file() and (expected_sha256 is None or sha256_file(path) == expected_sha256) + + +def jsonl_write(path: Path, items: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + with temporary.open("w", encoding="utf-8") as stream: + for item in items: + stream.write(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n") + os.replace(temporary, path) diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/merge_scans.py b/onshape_to_cdsl/src/onshape_to_cdsl/merge_scans.py new file mode 100644 index 00000000..8d0952e5 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/merge_scans.py @@ -0,0 +1,31 @@ +"""Combine non-overlapping scan batches into one deterministic selection input.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .manifests import read_json, sha256_file, write_json + + +def merge_scans(paths: list[Path], out: Path) -> dict[str, Any]: + if not paths: + raise ValueError("at least one scan manifest is required") + records: list[dict[str, Any]] = [] + source_hashes: list[dict[str, str]] = [] + seen: set[str] = set() + for path in paths: + scan = read_json(path) + for record in scan.get("records") or []: + sample_id = str(record.get("sample_id") or "") + if not sample_id: + raise ValueError(f"{path}: scan record has no sample_id") + if sample_id in seen: + raise ValueError(f"{path}: duplicate sample_id {sample_id}") + seen.add(sample_id) + records.append(record) + source_hashes.append({"path": str(path), "sha256": sha256_file(path)}) + records.sort(key=lambda item: str(item["sample_id"])) + manifest = {"schema": "onshape_to_cdsl.merged_scan.v1", "source_scans": source_hashes, "sample_count": len(records), "records": records} + write_json(out, manifest) + return manifest diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/onshape_api.py b/onshape_to_cdsl/src/onshape_to_cdsl/onshape_api.py new file mode 100644 index 00000000..af6c55f6 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/onshape_api.py @@ -0,0 +1,72 @@ +"""Minimal authenticated Onshape v17 client. Credentials never enter manifests.""" + +from __future__ import annotations + +import base64 +import getpass +import json +import os +import ssl +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from .source_urls import PartStudioRef + + +API_VERSION = "v17" +JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09" + + +def authorization_from_environment() -> str: + access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ") + secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ") + if not access_key or not secret_key: + raise ValueError("both ONSHAPE_ACCESS_KEY and ONSHAPE_SECRET_KEY are required") + return "Basic " + base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii") + + +class OnshapeClient: + def __init__(self, ref: PartStudioRef, authorization: str, timeout: float = 60.0) -> None: + self.ref, self.authorization, self.timeout = ref, authorization, timeout + try: + import certifi + self.context = ssl.create_default_context(cafile=certifi.where()) + except ImportError: + self.context = ssl.create_default_context() + + def url(self, path: str, query: dict[str, Any] | None = None) -> str: + encoded = urllib.parse.urlencode(query or {}, doseq=True) + return f"https://{self.ref.stack}/api/{API_VERSION}{path}" + (f"?{encoded}" if encoded else "") + + def request(self, method: str, path: str, *, query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, accept: str = JSON_ACCEPT) -> tuple[bytes, str]: + request = urllib.request.Request( + self.url(path, query), data=json.dumps(body).encode("utf-8") if body is not None else None, + method=method, headers={"Accept": accept, "Authorization": self.authorization, "Content-Type": JSON_ACCEPT, "User-Agent": "onshape-to-cdsl/0.1"}, + ) + # Public ABC URLs can be scanned in large batches. Respect Onshape's + # rate limit rather than recording a transient 429 as a dead document. + retry_limit = max(0, int(os.environ.get("ONSHAPE_RATE_LIMIT_RETRIES", "5"))) + for attempt in range(retry_limit + 1): + try: + with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response: + return response.read(), response.geturl() + except urllib.error.HTTPError as exc: + if exc.code != 429 or attempt == retry_limit: + raise + retry_after = exc.headers.get("Retry-After") + try: + pause = float(retry_after) if retry_after is not None else min(60.0, 2.0 ** attempt) + except ValueError: + pause = min(60.0, 2.0 ** attempt) + time.sleep(max(1.0, pause)) + raise RuntimeError("unreachable Onshape retry loop") + + def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any: + return json.loads(self.request("GET", path, query=query)[0].decode("utf-8")) + + +def part_studio_path(ref: PartStudioRef, suffix: str) -> str: + return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}" diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/registry.py b/onshape_to_cdsl/src/onshape_to_cdsl/registry.py new file mode 100644 index 00000000..14256118 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/registry.py @@ -0,0 +1,45 @@ +"""Explicit feature support contract. Unknown does not mean executable.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FeatureRule: + status: str + atomic_id: str | None = None + reason: str = "" + + +RULES: dict[str, FeatureRule] = { + "newSketch": FeatureRule("supported", reason="solved sketch is captured separately"), + "extrude": FeatureRule("supported", reason="only BLIND without draft is executable in v1"), + "revolve": FeatureRule("deferred", "revolve_add", "requires robust source axis/region resolution"), + "hole": FeatureRule("deferred", "hole_blind", "requires host face topology binding"), + "fillet": FeatureRule("deferred", "fillet", "requires stable source edge selector"), + "chamfer": FeatureRule("deferred", "chamfer", "requires stable source edge selector"), + "linearPattern": FeatureRule("deferred", "pattern_linear", "requires source feature references"), + "circularPattern": FeatureRule("deferred", reason="circular pattern is not implemented by the engine"), + "mirror": FeatureRule("deferred", "pattern_mirror", "requires source plane selector"), + "draft": FeatureRule("deferred", reason="draft executor is not implemented by the engine"), + "loft": FeatureRule("deferred", reason="loft executor is not implemented by the engine"), + "sweep": FeatureRule("deferred", reason="sweep executor is not implemented by the engine"), + "shell": FeatureRule("deferred", reason="shell executor is not implemented by the engine"), + "thicken": FeatureRule("deferred", reason="thicken executor is not implemented by the engine"), + "split": FeatureRule("deferred", reason="split executor is not implemented by the engine"), + "moveFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"), + "replaceFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"), + "deleteFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"), + "derive": FeatureRule("not_admissible", reason="external document dependency"), + "import": FeatureRule("not_admissible", reason="imported geometry has no local parametric source"), +} + + +def rule_for(feature_type: object) -> FeatureRule: + name = str(feature_type or "unknown") + if name in RULES: + return RULES[name] + if "custom" in name.lower() or "featurescript" in name.lower() or "derive" in name.lower() or "import" in name.lower(): + return FeatureRule("not_admissible", reason="custom FeatureScript, imported, or external feature") + return FeatureRule("deferred", reason=f"no registered local executor for Onshape feature {name}") diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/scan.py b/onshape_to_cdsl/src/onshape_to_cdsl/scan.py new file mode 100644 index 00000000..cc97cefc --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/scan.py @@ -0,0 +1,69 @@ +"""Feature-only scan used for selection; it deliberately does not export STEP.""" + +from __future__ import annotations + +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +from .manifests import sha256_file, write_json +from .onshape_api import OnshapeClient, authorization_from_environment, part_studio_path +from .registry import rule_for +from .source_urls import read_url_file + + +def _feature_summary(payload: Any) -> dict[str, Any]: + features = payload.get("features", []) if isinstance(payload, dict) else [] + types: Counter[str] = Counter() + states: Counter[str] = Counter() + for feature in features: + if not isinstance(feature, dict): + continue + feature_type = str(feature.get("featureType") or "unknown") + types[feature_type] += 1 + states[rule_for(feature_type).status] += 1 + return {"feature_count": sum(types.values()), "feature_types": dict(sorted(types.items())), "admissibility": dict(sorted(states.items()))} + + +def _curve_types(payload: Any) -> Counter[str]: + curves: Counter[str] = Counter() + features = payload.get("features", []) if isinstance(payload, dict) else [] + for feature in features: + if not isinstance(feature, dict) or feature.get("featureType") != "newSketch": + continue + for entity in feature.get("entities") or []: + geometry = entity.get("geometry") if isinstance(entity, dict) else None + if isinstance(geometry, dict): + curves[str(geometry.get("btType") or "unknown")] += 1 + return curves + + +def scan(url_file: Path, out: Path, *, timeout: float = 60.0, limit: int | None = None, offset: int = 0, workers: int = 1) -> dict[str, Any]: + authorization = authorization_from_environment() + if offset < 0: + raise ValueError("scan offset must be non-negative") + if workers < 1 or workers > 32: + raise ValueError("scan workers must be between 1 and 32") + refs = read_url_file(url_file) + if limit is not None: + if limit < 1: + raise ValueError("scan limit must be positive") + refs = refs[offset:offset + limit] + else: + refs = refs[offset:] + def inspect(ref: Any) -> dict[str, Any]: + entry: dict[str, Any] = {"sample_id": ref.sample_id, "source": ref.as_dict()} + try: + payload = OnshapeClient(ref, authorization, timeout).get_json(part_studio_path(ref, "/features"), {"rollbackBarIndex": -1, "includeGeometryIds": "true", "noSketchGeometry": "false"}) + summary = _feature_summary(payload) + summary["curve_types"] = dict(sorted(_curve_types(payload).items())) + entry.update({"status": "scanned", **summary}) + except Exception as exc: # A failed public document must remain auditable too. + entry.update({"status": "failed", "failure_reason": f"{type(exc).__name__}: {exc}"}) + return entry + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="onshape-scan") as executor: + records = list(executor.map(inspect, refs)) + manifest = {"schema": "onshape_to_cdsl.scan.v1", "url_file": str(url_file), "url_file_sha256": sha256_file(url_file), "source_record_count": len(read_url_file(url_file)), "scan_offset": offset, "scan_limit": limit, "scan_workers": workers, "sample_count": len(records), "records": records} + write_json(out, manifest) + return manifest diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/select.py b/onshape_to_cdsl/src/onshape_to_cdsl/select.py new file mode 100644 index 00000000..beca1d87 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/select.py @@ -0,0 +1,40 @@ +"""Deterministic stratified candidate selection.""" + +from __future__ import annotations + +import random +from collections import defaultdict +from pathlib import Path +from typing import Any + +from .manifests import read_json, sha256_file, write_json + + +def _stratum(record: dict[str, Any]) -> str: + feature_types = record.get("feature_types") or {} + curves = record.get("curve_types") or {} + feature_band = "1-3" if record.get("feature_count", 0) <= 3 else "4-8" if record.get("feature_count", 0) <= 8 else "9+" + return "|".join((",".join(sorted(feature_types)) or "none", ",".join(sorted(curves)) or "none", feature_band)) + + +def select(scan_manifest: Path, out: Path, *, count: int, seed: int) -> dict[str, Any]: + scan = read_json(scan_manifest) + eligible = [record for record in scan.get("records", []) if record.get("status") == "scanned"] + buckets: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in eligible: + buckets[_stratum(record)].append(record) + rng = random.Random(seed) + for values in buckets.values(): + rng.shuffle(values) + chosen: list[dict[str, Any]] = [] + # Round robin preserves uncommon strata while keeping a fixed seed order. + keys = sorted(buckets) + while len(chosen) < min(count, len(eligible)) and any(buckets.values()): + for key in keys: + if buckets[key] and len(chosen) < count: + item = dict(buckets[key].pop()) + item["stratum"] = key + chosen.append(item) + manifest = {"schema": "onshape_to_cdsl.selection.v1", "scan_manifest": str(scan_manifest), "scan_manifest_sha256": sha256_file(scan_manifest), "seed": seed, "requested_count": count, "selected_count": len(chosen), "records": chosen} + write_json(out, manifest) + return manifest diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/sketches.py b/onshape_to_cdsl/src/onshape_to_cdsl/sketches.py new file mode 100644 index 00000000..8d84ea19 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/sketches.py @@ -0,0 +1,120 @@ +"""Solved Onshape sketch geometry to self-contained CDSL analytic contours.""" + +from __future__ import annotations + +import math +from typing import Any + + +MM_PER_M = 1000.0 + + +def _vector(value: Any) -> list[float]: + if not isinstance(value, dict): + raise ValueError("missing Onshape 3D point") + return [float(value[key]) * MM_PER_M for key in ("x", "y", "z")] + + +def _dot(left: list[float], right: list[float]) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _subtract(left: list[float], right: list[float]) -> list[float]: + return [a - b for a, b in zip(left, right)] + + +def _cross(left: list[float], right: list[float]) -> list[float]: + return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]] + + +def _unit(value: list[float]) -> list[float]: + length = math.sqrt(_dot(value, value)) + if length < 1e-10: + raise ValueError("zero sketch coordinate direction") + return [component / length for component in value] + + +def workplane_from_matrix(matrix: Any) -> dict[str, list[float]]: + """Decode Onshape's row-major 4x4 sketch transform into millimetres.""" + if not isinstance(matrix, list) or len(matrix) != 16: + raise ValueError("sketchMatrix must contain 16 values") + values = [float(value) for value in matrix] + x_dir = _unit([values[0], values[1], values[2]]) + y_dir = _unit([values[4], values[5], values[6]]) + normal = _unit(_cross(x_dir, y_dir)) + # API sketchMatrix serializes a row-major transform; translation is 3,7,11. + return {"origin_mm": [values[3] * MM_PER_M, values[7] * MM_PER_M, values[11] * MM_PER_M], "x_dir": x_dir, "y_dir": y_dir, "normal": normal} + + +def _local(point: list[float], plane: dict[str, list[float]]) -> list[float]: + displacement = _subtract(point, plane["origin_mm"]) + return [_dot(displacement, plane["x_dir"]), _dot(displacement, plane["y_dir"])] + + +def _endpoint_key(point: list[float], tolerance_mm: float = 1e-5) -> tuple[int, int]: + return (round(point[0] / tolerance_mm), round(point[1] / tolerance_mm)) + + +def _ordered_contours(segments: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + """Join non-circular edges by endpoints; circles are independent closed contours.""" + circles = [[segment] for segment in segments if segment["type"] == "circle"] + edges = [segment for segment in segments if segment["type"] != "circle"] + unused = set(range(len(edges))) + contours: list[list[dict[str, Any]]] = [] + while unused: + current_index = unused.pop() + contour = [edges[current_index]] + first = _endpoint_key(contour[0]["start_mm"]) + end = _endpoint_key(contour[0]["end_mm"]) + while end != first: + match = next((index for index in unused if _endpoint_key(edges[index]["start_mm"]) == end or _endpoint_key(edges[index]["end_mm"]) == end), None) + if match is None: + raise ValueError("sketch has an open or disconnected profile contour") + unused.remove(match) + edge = dict(edges[match]) + if _endpoint_key(edge["end_mm"]) == end: + edge["start_mm"], edge["end_mm"] = edge["end_mm"], edge["start_mm"] + if edge["type"] == "arc": + edge["clockwise"] = not bool(edge.get("clockwise", False)) + contour.append(edge) + end = _endpoint_key(edge["end_mm"]) + contours.append(contour) + return contours + circles + + +def sketch_to_cdsl(sketch: dict[str, Any]) -> dict[str, Any]: + plane = workplane_from_matrix(sketch.get("sketchMatrix")) + segments: list[dict[str, Any]] = [] + unsupported: list[str] = [] + for entity in sketch.get("entities") or []: + if not isinstance(entity, dict) or entity.get("isConstruction"): + continue + entity_type = entity.get("sketchEntityType") + geometry = entity.get("geometry") or {} + try: + if entity_type == "skLineSegment": + segments.append({"type": "line", "start_mm": _local(_vector(entity["startPosition3d"]), plane), "end_mm": _local(_vector(entity["endPosition3d"]), plane)}) + elif entity_type == "skArc": + segments.append({"type": "arc", "start_mm": _local(_vector(entity["startPosition3d"]), plane), "end_mm": _local(_vector(entity["endPosition3d"]), plane), "center_mm": _local(_vector(geometry["center3d"]), plane), "radius_mm": float(geometry["radius"]) * MM_PER_M, "clockwise": bool(geometry.get("clockWise", False))}) + elif entity_type == "skCircle": + segments.append({"type": "circle", "center_mm": _local(_vector(geometry["center3d"]), plane), "radius_mm": float(geometry["radius"]) * MM_PER_M}) + elif entity_type != "skPoint": + unsupported.append(str(entity_type)) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"{entity.get('sketchEntityId', '?')}: {exc}") from exc + if unsupported: + raise ValueError("unsupported solved sketch entities: " + ", ".join(sorted(set(unsupported)))) + if not segments: + raise ValueError("sketch has no non-construction executable geometry") + contours = _ordered_contours(segments) + + def profile_segment(segment: dict[str, Any]) -> dict[str, Any]: + output = {key: value for key, value in segment.items() if key not in {"start_mm", "end_mm", "center_mm"}} + if "start_mm" in segment: + output["start"] = segment["start_mm"] + output["end"] = segment["end_mm"] + if "center_mm" in segment: + output["center"] = segment["center_mm"] + return output + + return {"id": str(sketch["featureId"]), "name": str(sketch.get("name") or sketch["featureId"]), "workplane": plane, "profile": {"type": "analytic_contours", "contours": [{"role": "unknown", "closed": True, "segments": [profile_segment(segment) for segment in contour]} for contour in contours]}} diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/source_urls.py b/onshape_to_cdsl/src/onshape_to_cdsl/source_urls.py new file mode 100644 index 00000000..58731c66 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/source_urls.py @@ -0,0 +1,66 @@ +"""Parse official ABC URL mapping files without depending on PyYAML.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import re +from pathlib import Path + + +ONSHAPE_URL_RE = re.compile( + r"^https://(?P[^/]+)/documents/(?P[^/]+)/(?Pw|v|m)/(?P[^/]+)/e/(?P[^/?#]+)" +) +URL_RE = re.compile(r"https://[^'\"\s]+/documents/[^'\"\s]+") +# ABC's source files are Python/YAML-style mapping literals: the first record +# starts with `{`, all following records are indented, and the final record +# ends in `}`. Accept the optional opening delimiter without parsing arbitrary +# YAML or evaluating source text. +LEADING_ID_RE = re.compile(r"^\s*(?:\{\s*)?(?:-\s*)?['\"]?(?P[A-Za-z0-9_-]+)['\"]?\s*(?::|\s)") + + +@dataclass(frozen=True) +class PartStudioRef: + sample_id: str + source_url: str + stack: str + did: str + wvm: str + wvmid: str + eid: str + + def as_dict(self) -> dict[str, str]: + return asdict(self) + + +def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef: + source_url = url.strip().rstrip("',\"]") + match = ONSHAPE_URL_RE.match(source_url) + if match is None: + raise ValueError(f"{sample_id}: not an Onshape Part Studio URL: {url}") + return PartStudioRef(sample_id=str(sample_id), source_url=source_url, **match.groupdict()) + + +def read_url_file(path: Path) -> list[PartStudioRef]: + """Read `id URL`, `id: URL`, or ABC objects YAML records. + + ABC mappings can contain unrelated fields. A URL line is accepted only + when its object ID appears on the same line, which prevents silently + associating a URL with an adjacent record. + """ + refs: list[PartStudioRef] = [] + seen: set[str] = set() + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + url = URL_RE.search(line) + if not url: + continue + identifier = LEADING_ID_RE.match(line) + if not identifier: + raise ValueError(f"{path}:{number}: Onshape URL requires a leading sample ID") + sample_id = identifier.group("id") + if sample_id in seen: + raise ValueError(f"{path}:{number}: duplicate sample ID {sample_id!r}") + seen.add(sample_id) + refs.append(parse_part_studio_url(sample_id, url.group(0))) + if not refs: + raise ValueError(f"{path}: no Onshape Part Studio URLs found") + return refs diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/units.py b/onshape_to_cdsl/src/onshape_to_cdsl/units.py new file mode 100644 index 00000000..a1f21802 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/units.py @@ -0,0 +1,18 @@ +"""Conservative parser for Onshape length expressions used by standard features.""" + +from __future__ import annotations + +import re + + +_LENGTH_RE = re.compile(r"^\s*(?P[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?(?Pmm|millimeter(?:s)?|cm|m|meter(?:s)?|in|inch|inches|ft|foot|feet)\s*$", re.I) +_SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0, "meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "ft": 304.8, "foot": 304.8, "feet": 304.8} + + +def length_mm(expression: object) -> float: + if not isinstance(expression, str): + raise ValueError("missing length expression") + match = _LENGTH_RE.match(expression) + if not match: + raise ValueError(f"unsupported length expression {expression!r}") + return float(match.group("value")) * _SCALE[match.group("unit").lower()] diff --git a/onshape_to_cdsl/src/onshape_to_cdsl/validate.py b/onshape_to_cdsl/src/onshape_to_cdsl/validate.py new file mode 100644 index 00000000..64db0661 --- /dev/null +++ b/onshape_to_cdsl/src/onshape_to_cdsl/validate.py @@ -0,0 +1,59 @@ +"""CDSL-only rebuild, strict comparison, and accepted/rejected dataset routing.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from .compare import strict_compare +from .issues import write_issue_register +from .manifests import read_json, sha256_file, write_json + + +def validate_one(converted_dir: Path, dataset_root: Path, rejected_root: Path) -> dict[str, Any]: + conversion = read_json(converted_dir / "conversion.json") + sample_id = str(conversion["sample_id"]) + output = converted_dir / "rebuild.step" + report: dict[str, Any] = {"schema": "onshape_to_cdsl.validation.v1", "sample_id": sample_id, "conversion_status": conversion.get("status")} + if conversion.get("status") != "converted": + report.update({"status": "rejected", "failure_reason": "conversion_not_executable"}) + else: + try: + from cdsl_engine.runtime import rebuild_cdsl + cdsl = read_json(converted_dir / "candidate.cdsl.json") + report["rebuild"] = rebuild_cdsl(cdsl, output, strict=True) + report["rebuild"]["engine"] = "cdsl_session_runtime" + gold = Path(str(conversion["raw_dir"])) / "model.step" + report["comparison"] = strict_compare(gold, output) + report["status"] = "accepted" if report["comparison"]["passed"] else "rejected" + if report["status"] == "rejected": + report["failure_reason"] = ",".join(report["comparison"]["failure_reasons"]) + except Exception as exc: + report.update({"status": "rejected", "failure_reason": f"{type(exc).__name__}: {exc}"}) + if report["status"] == "accepted": + dataset_root.mkdir(parents=True, exist_ok=True) + destination = dataset_root / f"{sample_id}.cdsl.json" + shutil.copy2(converted_dir / "candidate.cdsl.json", destination) + report["dataset_cdsl"] = str(destination) + report["dataset_cdsl_sha256"] = sha256_file(destination) + else: + destination = rejected_root / sample_id + destination.mkdir(parents=True, exist_ok=True) + for name in ("candidate.cdsl.json", "rebuild.step", "conversion.json"): + source = converted_dir / name + if source.exists(): + shutil.copy2(source, destination / name) + report["rejection_dir"] = str(destination) + write_json(converted_dir / "validation.json", report) + if report["status"] == "rejected": + shutil.copy2(converted_dir / "validation.json", Path(report["rejection_dir"]) / "validation.json") + return report + + +def validate(converted_root: Path, dataset_root: Path, rejected_root: Path) -> dict[str, Any]: + records = [validate_one(path, dataset_root, rejected_root) for path in sorted(converted_root.iterdir()) if path.is_dir() and (path / "conversion.json").exists()] + issues = write_issue_register(converted_root, records) + manifest = {"schema": "onshape_to_cdsl.validation_summary.v1", "converted_root": str(converted_root), "sample_count": len(records), "accepted_count": sum(item["status"] == "accepted" for item in records), "rejected_count": sum(item["status"] == "rejected" for item in records), "reconstruction_issues": {"path": str(converted_root.parent / "reconstruction_issues.md"), "issue_count": issues["issue_count"], "summary": issues["summary"]}, "records": records} + write_json(converted_root / "validation-manifest.json", manifest) + return manifest diff --git a/onshape_to_cdsl/tests/test_pipeline.py b/onshape_to_cdsl/tests/test_pipeline.py new file mode 100644 index 00000000..42aed6ed --- /dev/null +++ b/onshape_to_cdsl/tests/test_pipeline.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import tempfile +import unittest + +from onshape_to_cdsl.compare import strict_compare +from onshape_to_cdsl.convert import convert_one +from onshape_to_cdsl.issues import write_issue_register +from onshape_to_cdsl.merge_scans import merge_scans +from onshape_to_cdsl.select import select +from onshape_to_cdsl.sketches import sketch_to_cdsl, workplane_from_matrix +from onshape_to_cdsl.source_urls import read_url_file +from onshape_to_cdsl.units import length_mm + + +class PipelineTests(unittest.TestCase): + def test_url_file_and_units(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + mapping = Path(temporary) / "objects.yml" + mapping.write_text("{'00000352': 'https://cad.onshape.com/documents/doc/w/work/e/element'}\n", encoding="utf-8") + reference = read_url_file(mapping)[0] + self.assertEqual(reference.sample_id, "00000352") + self.assertEqual(reference.did, "doc") + self.assertAlmostEqual(length_mm(".063 in"), 1.6002) + self.assertAlmostEqual(length_mm("1.2*cm"), 12.0) + with self.assertRaises(ValueError): + length_mm("width") + + def test_solved_rectangle_becomes_closed_analytic_contour(self) -> None: + matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, .002, 0, 0, 0, 1] + self.assertEqual(workplane_from_matrix(matrix)["origin_mm"], [0.0, 0.0, 2.0]) + point = lambda x, y: {"x": x / 1000, "y": y / 1000, "z": .002} + entities = [] + for start, end in [((0, 0), (10, 0)), ((10, 0), (10, 5)), ((10, 5), (0, 5)), ((0, 5), (0, 0))]: + entities.append({"sketchEntityType": "skLineSegment", "isConstruction": False, "startPosition3d": point(*start), "endPosition3d": point(*end)}) + cdsl = sketch_to_cdsl({"featureId": "sketch_one", "sketchMatrix": matrix, "entities": entities}) + contour = cdsl["profile"]["contours"][0] + self.assertTrue(contour["closed"]) + self.assertEqual(len(contour["segments"]), 4) + self.assertEqual(contour["segments"][0]["type"], "line") + + def test_conversion_rejects_unsupported_history(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "raw" / "sample_01" + root.mkdir(parents=True) + (root / "features.json").write_text(json.dumps({"features": [{"featureId": "x", "featureType": "loft", "parameters": []}]}), encoding="utf-8") + (root / "sketches.json").write_text(json.dumps({"sketches": []}), encoding="utf-8") + result = convert_one(root, Path(temporary) / "converted") + self.assertEqual(result["status"], "rejected") + self.assertEqual(result["diagnostics"][0]["status"], "deferred") + + def test_issue_register_summarizes_engine_capability_gaps(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + converted = Path(temporary) / "converted" + sample = converted / "sample_01" + sample.mkdir(parents=True) + (sample / "conversion.json").write_text(json.dumps({"sample_id": "sample_01", "status": "rejected", "diagnostics": [{"feature_id": "loft_1", "feature_type": "loft", "status": "deferred", "reason": "loft executor is not implemented by the engine"}]}), encoding="utf-8") + register = write_issue_register(converted, [{"sample_id": "sample_01", "status": "rejected", "failure_reason": "conversion_not_executable"}]) + self.assertEqual(register["summary"], {"engine_capability_gap": 1}) + document = (Path(temporary) / "reconstruction_issues.md").read_text(encoding="utf-8") + self.assertIn("Engine Work Queue", document) + self.assertIn("loft executor", document) + + def test_deterministic_stratified_selection(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + scan = Path(temporary) / "scan.json" + records = [{"sample_id": str(index), "status": "scanned", "feature_count": index, "feature_types": {"extrude": 1}, "curve_types": {"line": 4}} for index in range(8)] + scan.write_text(json.dumps({"records": records}), encoding="utf-8") + one = select(scan, Path(temporary) / "one.json", count=4, seed=7) + two = select(scan, Path(temporary) / "two.json", count=4, seed=7) + self.assertEqual([item["sample_id"] for item in one["records"]], [item["sample_id"] for item in two["records"]]) + + def test_merge_scan_batches_rejects_duplicate_ids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + first, second = root / "first.json", root / "second.json" + first.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "scanned"}]}), encoding="utf-8") + second.write_text(json.dumps({"records": [{"sample_id": "0002", "status": "failed"}]}), encoding="utf-8") + merged = merge_scans([first, second], root / "merged.json") + self.assertEqual([item["sample_id"] for item in merged["records"]], ["0001", "0002"]) + second.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "failed"}]}), encoding="utf-8") + with self.assertRaises(ValueError): + merge_scans([first, second], root / "bad.json") + + def test_strict_comparator_accepts_identical_and_rejects_translation(self) -> None: + from build123d import Box, export_step + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, same, shifted = root / "source.step", root / "same.step", root / "shifted.step" + box = Box(10, 10, 10) + export_step(box, source) + export_step(box, same) + moved = Box(10, 10, 10).translate((0.02, 0, 0)) + export_step(moved, shifted) + self.assertTrue(strict_compare(source, same)["passed"]) + rejection = strict_compare(source, shifted) + self.assertFalse(rejection["passed"]) + self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"]) + + def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None: + fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352" + if not (fixture / "model.step").exists(): + self.skipTest("optional local 00000352 raw fixture is not installed") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + raw = root / "raw" / "00000352" + raw.parent.mkdir() + shutil.copytree(fixture, raw) + result = convert_one(raw, root / "converted") + self.assertEqual(result["status"], "converted") + from cdsl_engine.runtime import rebuild_cdsl + from cdsl_engine.semantic_validation import validate_semantic_cdsl + cdsl_path = root / "converted/00000352/candidate.cdsl.json" + cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) + validate_semantic_cdsl(cdsl) + rebuilt = root / "rebuild.step" + rebuild_cdsl(cdsl, rebuilt, strict=True) + comparison = strict_compare(raw / "model.step", rebuilt) + self.assertTrue(comparison["passed"], comparison["failure_reasons"]) + + +if __name__ == "__main__": + unittest.main()