Compare commits

5 Commits

Author SHA1 Message Date
likang de21ee8a5b feat(cadfs): expand selector provenance coverage 2026-09-14 11:11:12 +08:00
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00
likang d8fc2a9207 Merge branch 'codex/integrate-ganjihong-refactor-2' into lk_dev
# Conflicts:
#	backend/app/cad_agent/domain/operation_contract.py
#	backend/engine/cdsl_engine/runtime.py
#	backend/engine/cdsl_engine/runtime_types.py
#	backend/engine/cdsl_engine/semantic_validation.py
2026-09-09 18:50:31 +08:00
likang 21a5185926 integrate ganjihong engine refactor 2026-09-09 18:41:56 +08:00
likang 387b99e255 wip: preserve local engine and agent workflow changes before ganjihong refactor integration 2026-09-09 17:08:24 +08:00
151 changed files with 32471 additions and 13624 deletions
+2
View File
@@ -32,8 +32,10 @@ build/
*.tsbuildinfo *.tsbuildinfo
# Runtime data and generated local artifacts # Runtime data and generated local artifacts
output/
backend/data/ backend/data/
backend/live-evals/ backend/live-evals/
cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md
data/cadfs-sample/ data/cadfs-sample/
json_to_cdsl/input/ json_to_cdsl/input/
onshape_to_cdsl/input/ onshape_to_cdsl/input/
+101
View File
@@ -29,3 +29,104 @@
## 改动评审指引 ## 改动评审指引
修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。 修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。
## CADFS 全量能力开发
CADFS 到 CDSL 到 STEP 的工作是在实现通用绘图引擎和通用 converter,不是为回归样本
编写修复脚本。能力实现必须以 FeatureScript 语义、显式 CDSL contract、body
生命周期和内核拓扑为边界,并对同类输入普遍成立。
- 禁止按 `sample_id`、路径、特定 feature ID、特定坐标、尺寸或 gold STEP 测量值分支;
禁止从原 STEP 回填 FeatureScript 未提供的参数,或为某模型硬编码 selector、profile、
偏移量、布尔策略和默认尺寸。
- 不得将不支持或不唯一的语义伪装成盲拉伸、当前 body、默认 union、任意相近拓扑元素
或静默跳过。必须保留最佳可执行前缀和 STEP,并给出稳定、可归因的能力诊断。
- 优先在 schema、semantic validation、runtime state、body graph 和 adapter 的
kernel-level topology delta 中实现能力;lowering 层只能表达源语义,不能承担样本化
的几何补丁。受限算法必须有通用、可验证的适用条件和拒绝路径。
- 一项能力只有在 lowering、CDSL contract、runtime/adapter、selector/body 语义、
单元测试和多个真实语料回归均具备后才能标为完成。操作名称覆盖不等于参数、拓扑来源、
几何类型或 body 生命周期覆盖。
- 严格比较用于诊断;工程验收遵循 `rp.passed`。不得降低 RP 阈值、关闭诊断、跳过
feature 或修改 source 参数来换取通过。source STEP 损坏或历史/STEP 精度不一致必须
作为有证据的 source exception 单独报告。
- 每次涉及 CADFS lowering、CDSL schema、runtime、adapter、selector 或测试的改动,
都必须同步更新 `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;全量能力
计划以 `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` 为准。
## CADFS 修改、文档与回归纪律
以下规则适用于每一次 CADFS converter、CDSL engine、selector、比较器、回归集或相关
测试的修改;它们是仓库规则,不依赖当前对话上下文。
### 修改前与实现方式
- 先用完整 FeatureScript history、`candidate.cdsl.json`、`bound.cdsl.json`、
`diagnostics.json`、`rebuild.json`、`comparison.json` 和 source/rebuild 工件定位失败层:
converter/lowering、schema/semantic validation、selector binding、runtime/adapter、
source STEP/FeatureScript 不一致,或 comparison infrastructure。没有证据不得把失败
归因于任一层,也不得先改阈值或样本数据。
- 修改必须遵循目标模块已有的命名、分层、格式、注释语言和错误处理模式。优先扩展既有
contract、helper 和架构边界;没有明确收益时不得进行无关重构或引入平行实现。
- 先实现可复用的几何和拓扑语义,再将 CADFS source lower 到该 contract。任何只为单个
形状、数值、feature history 或截图成立的逻辑均视为缺陷,不得提交。
- 内核无法完成某个输入时,保留有界的失败和诊断;不得用较小的 dress-up 半径、替代孔型、
固定 extent、隐式 fuse 或未经来源证明的几何补偿伪造结果。
- 当前实现若被证明确实违反 source/contract 语义、无法表达全量已出现的通用输入、
受内核 API 的结构性限制,或反复需要样本化补丁,应停止继续叠加补丁并评估替代方案。
替换前必须具备可复现失败、根因证据、与成熟开源/官方 API 或最小原型的对照、对
CDSL/body/selector 兼容性的影响评估,以及受影响回归的迁移计划。
- 不得因单个样本、偶发 OCC 失败、一次性能波动或主观偏好轻易重写成熟路径。只有新方案
能以更少特例、更完整的通用语义和可验证的回归证据解决结构性问题时,才替换旧方案;
替换过程中保留旧工件和比较基线,分阶段迁移并记录回滚边界。
### 文档同步
- 每次实现、修复、扩展或确认某项 CADFS 能力后,必须在同一工作变更中更新
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`:记录能力状态、通用语义、
已验证边界、未覆盖边界、受影响样本/能力矩阵和测试/比较证据;完成项必须勾选,
未完成项不得因单个样本通过而勾选。
- 上述 `.local.md` 是本地工作台账,必须保持在 `.gitignore` 中,绝不加入 Git 提交。
每次 CADFS 相关代码变更后都要同步,即使最终只得到失败诊断或发现 source exception。
- 新增能力、能力范围、验收口径、全量快照或实施优先级变化时,同步更新受版本控制的
`cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`。它定义全量能力矩阵和路线;
`CADFS_RECONSTRUCTION_TARGET.md` 记录核心回归与当前近期目标,两者不得冲突。
- 从全量 output 发现新的操作、参数变体、拓扑来源、body lifecycle 或 comparison failure
类型时,必须先登记为待办和能力矩阵条目,再选择多个真实样本回归;不得只加入一个
“代表模型”就宣称覆盖完成。
- 文档中的计数、样本 ID、状态和通过标准必须来自当前工件。必须区分 strict、RP 工程相似、
几何拒绝、可执行前缀、转换/运行时失败、比较超时和 source exception。
### 回归与可视证据
- 每次代码修改至少运行受影响的原子/单元测试和真实语料样本;涉及共享 runtime、
selector、body lifecycle 或 comparison 时,还必须运行核心 17、相应扩展能力矩阵和
可控的全量 shard。报告未运行的范围和原因,不能将缓存旧工件当作新代码的证据。
- 对用户要求查看或人工判定的样本,生成并保留 source STEP 与 rebuild STEP 的一致视角
对比截图;截图只辅助人工审查,最终分类仍以完整 history、B-rep 比较和诊断为准。
- 任何失败都保留最后可执行 STEP、GLB(如可生成)、comparison/diagnostic 工件和前缀
信息。不可执行或不相似不允许清理、覆盖或隐藏已有可用工件。
- 提交前执行与改动比例相称的测试、`git diff --check`,并确认本地能力台账未被 staged。
除非用户明确要求,不得提交、push、覆盖用户未提交修改或变更回归基线。
### 开源实现与 SimpleCADAPI 参考
- 在自行设计 shell、sweep、loft、boolean、fillet/chamfer、transform、topology tracking、
body graph 或比较基础设施前,必须先检索成熟的开源实现、官方 OCCT/OCP API 和已有
项目依赖;优先复用经过测试的算法、内核调用模式或小范围实现,而不是重新发明基础
B-rep 算法。外部代码的许可证、版本兼容性、异常语义和维护状态必须先核实。
- 本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`。重点阅读其
`src/simplecadapi/topology/tracking.py`、`kernel/ocp_booleans.py`、
`kernel/ocp_topology.py`、`kernel/ocp_transforms.py` 及对应 tests/docs:其中的
OCC builder history、`Modified`/`Generated`/`IsDeleted`、section edges、same-domain
cleanup、显式 shape transform、shell/sweep/loft 调用边界是当前 engine 的优先参考。
- 借鉴必须经过本项目的 CDSL schema、runtime body graph、selector provenance 和
回归测试适配。不得直接替换本项目的 CDSL contract,也不得照搬其“强制单一 Solid”
的 union/cut 语义,因为 CADFS 需要保留独立 body、copy、keep tools 和 pattern
instance 生命周期。
- 每次因开源参考新增或调整能力时,在本地能力台账记录参考来源、采用的通用语义、
未采用部分及理由、许可证/依赖影响和本项目回归证据。无法安全采用时,也应记录
评估结论,避免后续重复实现或重复调研。
- 发现现有方案方向错误或外部方案明显更适合时,可替换而不是继续打补丁;但必须满足
“修改前与实现方式”中的结构性根因和对照验证门槛,并在台账记录替换理由、迁移影响、
保留的 contract、回归结果和可回滚边界。
+1 -14
View File
@@ -2,7 +2,7 @@
# CDSL_DEFAULT_PROVIDER=deepseek # CDSL_DEFAULT_PROVIDER=deepseek
# CDSL_DEFAULT_MODEL=deepseek-v4-flash # CDSL_DEFAULT_MODEL=deepseek-v4-flash
CDSL_DEFAULT_PROVIDER=openai CDSL_DEFAULT_PROVIDER=openai
CDSL_DEFAULT_MODEL=gpt-5.4-mini CDSL_DEFAULT_MODEL=gpt-5.5
# DeepSeek. Fill in your own API key below. # DeepSeek. Fill in your own API key below.
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
@@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
CDSL_LLM_TIMEOUT_S=90 CDSL_LLM_TIMEOUT_S=90
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Final autonomous-task publication uses this independent vision reviewer.
CDSL_REVIEW_PROVIDER=openai
CDSL_REVIEW_MODEL=gpt-5.5
# Optional OpenAI provider. Comma-separate enabled models; list vision models # Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely. # separately so image attachments can be routed safely.
@@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
CDSL_KIMI_API_KEY= CDSL_KIMI_API_KEY=
CDSL_KIMI_MODELS=moonshot-v1-8k CDSL_KIMI_MODELS=moonshot-v1-8k
CDSL_KIMI_VISION_MODELS= CDSL_KIMI_VISION_MODELS=
# Autonomous CDSL agent limits. These protect an active modelling head, not
# the total feature count of a CAD task.
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000
CDSL_AGENT_RENDER_CACHE=true
+20 -37
View File
@@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium
``` ```
Use `low`, `medium`, or `high` according to the latency/cost versus quality Use `low`, `medium`, or `high` according to the latency/cost versus quality
tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to tradeoff. The setting is sent as the provider's `reasoning_effort` field to
authoring, streaming, and visual-review requests. Leave it empty to use the requirements analysis and Authoring CDSL generation. Leave it empty to use the
provider/model default. The selected OpenAI-compatible endpoint must support provider/model default. The selected OpenAI-compatible endpoint must support
the requested value. the requested value.
## Autonomous CDSL Agent Configuration ## Autonomous CDSL Agent Configuration
The autonomous agent writes one frozen free-form `requirements.md`, then Each task has one bounded workflow:
observes, measures, renders and appends one CDSL feature at a time. Its author
uses normal function calls; no provider strict JSON Schema capability or
complete modelling DAG is required. Candidate fragments are rebuilt in a
staging directory through `cdsl_only` before a checkpoint can be committed.
Final publication requires a separately configured vision-capable review model ```text
and the Python OpenCascade/Pillow technical renderer. The agent may build and request analysis -> complete cad.author.v1 -> server compilation -> runtime build
inspect intermediate checkpoints without image review; a final run fails -> at most two complete repairs -> final or best-effort publication
closed if its independent review configuration is unavailable. ```
The model outputs only local body/feature names and declarative selectors.
The server validates strict schemas, allocates Runtime CDSL identities,
compiles references, executes dependencies, and preserves the last executable
prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow
render bundle are generated from the same published revision.
```dotenv ```dotenv
# Must name one configured provider and one model listed in that provider's
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
# from the authoring model.
CDSL_REVIEW_PROVIDER=deepseek
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Install Python rendering dependencies. The renderer reads the revision STEP # Install Python rendering dependencies. The renderer reads the revision STEP
# file and creates canonical images without a browser or GPU driver. # file and creates canonical images without a browser or GPU driver.
pip install -r requirements.txt pip install -r requirements.txt
# Limits apply to the current checkpoint head, never to total task complexity.
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000
CDSL_AGENT_RENDER_CACHE=true
``` ```
The author chooses each coherent 1-6 feature batch. Every rebuilt batch is The initial generation plus two repairs are the only model calls allowed after
rendered and independently reviewed before it can become a checkpoint; only requirements analysis. A repair returns a complete replacement Authoring CDSL
an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the and may only alter diagnosed features. Runtime selector ambiguity, missing
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and selectors, and unavailable dependencies produce stable diagnostics rather than
reports are available only after the task reaches `COMPLETED`. topology guesses. Requirement compliance is reported independently as
`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is
The backend assigns feature and sketch IDs, appends causal dependencies and still published after the repair budget is exhausted.
expands only opaque current-snapshot selector tokens. It does not compile
geometry templates or correct workplanes, profiles, sizes, directions or
boolean semantics authored by the model. Failed candidates remain auditable
but never become revisions.
+233
View File
@@ -0,0 +1,233 @@
# Authoring CDSL Modeling Guide
Write one complete `cad.author.v1` document. It is declarative source for a
server compiler, not the runtime CDSL and not an execution log. Return only
the schema-valid object requested by the tool.
## Contract Boundary
Use lower-case local `name` values for bodies and features. They are symbols
within this document only. Never emit an `id`, `feature_id`, `sketch_id`,
`body_id`, `task_id`, revision, candidate, stable topology identifier,
snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
inside `params`. The server allocates identities and injects selector values
into the destination stated by the operation contract.
Literal request language such as `max_z`, `min_z`, `host face`, or a face name
is design intent, never a CDSL field or output role. Never emit `host_face`,
`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in
any JSON value, including `selector.source`. A selector is only `{kind,
source, match}`, and `source` must use an exact role advertised by the producer
operation.
Treat `requirements`, prior documents, and diagnostics as semantic input, not
CDSL templates. Their keys are descriptive labels. Copy geometry, values, and
relations only into fields allowed by the active schema and operation contract.
Thus `host_face: "max_z"` conveys placement intent, never output strings;
derive a selector from advertised roles or use explicit coordinates.
Use millimetres and a right-handed coordinate system unless the request says
otherwise. Put every reasonable but unstated design choice in `assumptions`.
Do not turn an assumed dimension into a deterministic acceptance target.
## Modeling Brief
Identify explicit dimensions, datums, directions, primary volumes, removals,
repetitions, finish features, targets, and noncritical assumptions. Explicit
dimensions take precedence over inferred proportions. Clarify only when an
omitted interface, scale, safety, or compliance value prevents construction;
otherwise record a practical default in `assumptions`.
## Construction Order
Choose the simplest supported construction whose parameters directly express
the requested dimensions. Use a stable order:
1. establish the body and functional coordinate frame;
2. create primary additive volume(s);
3. create any selector-hosted feature before its named source output is
changed by a fuse, cut, shell, pattern, or finishing operation;
4. add remaining bosses, ribs, and other major additive geometry;
5. make remaining pockets, bores, and through features;
6. apply patterns, then fillets and chamfers last.
Every feature must list each feature it actually uses in `depends_on`.
Prefer one complete profile-driven feature for a planar silhouette. Use
primitives when their axis, radius, and height directly express the part.
For through cuts, choose the operation's through extent and make the tool
cross the material; never rely on coincident faces or a guessed nearby face.
Delay dress-up operations because they can alter downstream topology.
## Operation Semantics
Apply the following rules from the supplied operation contracts. They describe
the CDSL language itself and do not prescribe a particular part design.
### Hard Emission Boundaries
Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is
not expressed by document-local names, so it cannot provide a reliable
model-facing construction path. Express additions with an additive operation
whose declared result mode supports the intended connected result, and express
removals with a cut operation and a sketch.
Prefer self-contained operations. An unavailable selector makes a hosted
operation unavailable even if requested; use a selector-free construction that
preserves geometry and record the method limitation. Every selector must pass
the exact-role and active-source checks.
- A primitive axis starts at `origin_mm` and grows in the supplied positive
`direction`. Heights and distances are positive magnitudes. A direction of
`[0,0,-1]` therefore models travel toward `-Z` without a negative height.
- A sketch has exactly one `profile`. A circle is one closed contour and a
polygon is one simple, non-self-intersecting closed loop. Never concatenate
disconnected circles or multiple polygon loops into one profile, and never
repeat a loop's start point to begin another loop.
- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`;
`source_feature_ids` contains local names and the count includes the seed.
If a repeated removal has no live host selector, use a selector-free cut seed
plus circular `operation_mode: "remove"`. Otherwise use one valid feature
per unrelated instance with its true dependency.
- Treat every operation schema as independent. Do not copy a parameter name,
enum value, or field shape from a similar operation into another operation;
emit only fields present in that feature's supplied `params_schema`.
- To extend an already connected solid, use an additive operation with the
explicit `result_mode: "fuse"` when its contract provides that mode. The new
profile must intersect the prior solid, and the feature depends on the prior
feature. Do not turn touching or overlapping primitive additions into
independent bodies followed by a boolean union.
- A primitive operation whose authoring schema has no `result_mode: "fuse"`
must be treated as a body-producing operation, not as a guaranteed extension
of an existing connected result. Use such an operation as an initial or
explicitly independent construction only. When the requested result must be
one connected body, continue it through an additive operation that explicitly
supports fusion rather than joining primitive outputs later with a boolean.
- Use a through-cut operation with a profile that intersects the material for
a through removal. Build all intended material before a removal that must
cross it. Do not make a temporary tool body and call `boolean_bodies` for a
removal expressible as a cut operation.
- Do not introduce an auxiliary feature solely to construct or delete a tool
for an operation that the contract already represents directly. A deletion
is a state-changing operation, never a no-op cleanup step, and downstream
dependencies must not rely on deleted geometry.
- Use a selector-hosted operation only when its required source output role is
available and the target geometry unambiguously lies on that output. Supply
every required selector and parameter from the operation contract; otherwise
choose a self-contained operation whose coordinates express the intent.
- A face-hosted extrusion reuses its host boundary. For any different boundary,
use a sketch-profile operation at an explicit workplane.
- Selector provenance is live. Do not select a source changed by an intervening
fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a
later host; schedule host-dependent features before it or use coordinates.
For `max_z`/`min_z` placement intent, use an owner role such as
`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never
`max_z_face` or `min_z_face`; prefer explicit coordinates when available.
Use only supplied operations and their exact parameter schemas. Do not invent
parameters, implicit booleans, or substitutes after a capability error.
## Sketches And Coordinates
Keep a sketch to exactly `workplane` and `profile`. The workplane declares its
origin, `x_dir`, and normal. Profile coordinates are local to that workplane.
For primitive axes, `origin_mm` is the start-cap center and `direction` is the
positive build direction. For a selector-hosted hole, position coordinates are
world coordinates unless the operation contract explicitly says otherwise.
The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For
every sketch operation, emit exactly this shape. `profile` is singular,
circles use the requested `diameter_mm`, and the local center is `center_mm`:
```json
{
"workplane": {
"origin_mm": [0, 0, 12],
"x_dir": [1, 0, 0],
"normal": [0, 0, 1]
},
"profile": {
"type": "circle",
"diameter_mm": 56,
"center_mm": [0, 0]
}
}
```
Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any
other key inside an Authoring sketch. The compiler derives Runtime radius and
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
and height directly express the requested geometry and no sketch is needed.
Name features for their geometric role, for example `primary_add`,
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
repair diagnostics readable; they are not server identities.
## Selectors
Operation metadata defines selector need, kind, cardinality, and server-side
destination. Put only declarative selectors in `selectors`, never the
destination field in `params`.
For an output face, use an exact local role embedded in `source` and state a
unique match:
```json
{
"kind": "face",
"source": "source_feature.top_planar_face",
"match": "unique"
}
```
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
Runtime token. The compiler adds the selector source dependency; declare other
true construction dependencies yourself.
`top_planar_face`/`end_face` mean a positive-direction cap;
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
host, never a similar face by location, index, or proximity.
For a cap, `source` MUST match
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
No other role spelling exists in Authoring CDSL. If the producer does not
advertise one of these roles, it cannot be the selector source.
Translate descriptive maximum/minimum placement to an appropriate cap role;
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
top cap and `bottom_planar_face` for its bottom cap.
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
and contain every position. Execute it before its source is replaced; otherwise
use a supported self-contained construction or report the limitation.
## Acceptance And Repair
Put only user-requested measurable targets in `acceptance_targets`; leave
inferred dimensions in `assumptions`. On repair return a complete document,
preserve executed features unless diagnosed, and change unexecuted features
only as needed. Read diagnostics literally; do not add IDs, weaken requested
values, silently delete a failed feature, or guess a selector.
## Pre-Emission Check
Before returning the document, check every feature against these invariants:
1. The operation is in the supplied whitelist, its params use only the
supplied closed schema, and its sketch/selector presence matches the
operation contract. The document contains no `boolean_bodies` feature.
2. Each reference is a local name, each dependency is necessary, and no
operation relies on a deleted, transient, or merely assumed body.
3. Each sketch contains one valid profile and a complete right-handed
workplane; each dimension is positive where the contract requires a
magnitude.
4. Each selector source has the exact `<feature>.<output_role>` form. Its role
is one advertised by that producer operation, not a descriptive synonym,
coordinate extreme, or inferred face name. For a cap, verify the required
four-role source regex character-for-character.
5. Every requested removal intersects its intended material, every repeated
feature has a valid seed reference, and every connected addition uses an
operation whose contract explicitly supports the chosen result mode.
If any invariant is false, revise the construction before emitting the single
complete `cad.author.v1` document.
@@ -1 +0,0 @@
以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。
@@ -1 +0,0 @@
先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。
@@ -1 +0,0 @@
把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。
@@ -1 +0,0 @@
世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。
@@ -1 +0,0 @@
优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。
@@ -1 +0,0 @@
轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。
@@ -1 +0,0 @@
宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。
@@ -1 +0,0 @@
对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。
@@ -1 +0,0 @@
圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。
@@ -1 +0,0 @@
确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。
@@ -1 +0,0 @@
修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。
@@ -1,63 +0,0 @@
# CDSL Author Guidance Corpus
This corpus is a Chinese-first, non-authoritative author aid. The runtime
operation contract, fragment schema, topology/reference tokens, preflight and
verifier evidence always win over these Markdown files. The manifest maps
only workflow phase, scheduled atomic operation and repair state; it never
classifies the user's part request.
## Source Migration
| Source reference | CDSL target sections | Intentionally excluded |
| --- | --- | --- |
| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow |
| `parameters.md` | `02`, `07` | sidecars, animation, viewer control |
| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement |
| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source |
| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set |
| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax |
| `snapshot-review.md` | `09` | renderer commands |
| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax |
| `step-generation.md` | `00`, `09` | Python generator commands |
| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags |
## Selection Contract
- Requirements authoring selects `00` to `03`.
- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`.
- A scheduled feature selects `00`, `03` to `06`, `08`, and its current
operation appendix.
- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix.
- Final validation selects `00`, `09`, and `10`.
At a bounded prompt budget, contract, coordinate/datum, and the scheduled
operation appendix are mandatory. Other sections are included in stable
priority order. A malformed corpus or an unsupported operation registry
falls back to the original short author prompt and records fallback metadata
with the author usage record.
## Evaluation Commands
Run the six matched control scenarios three times each, first without and
then with guidance:
```bash
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
--scenario double_hole_linkage_arm --scenario l_bracket
PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \
--scenario rectangular_mounting_plate --scenario circular_flange_pcd \
--scenario obround_slot_plate --scenario rounded_rectangular_pocket \
--scenario double_hole_linkage_arm --scenario l_bracket
PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json
```
The comparator excludes declared validation gaps and engine-declared
`unsupported_*` capability gaps from prompt quality metrics, checks paired
model/runtime/contract/budget equivalence, and
requires the treatment's checkpoint/completion rates not to regress, median
author calls to stay within 10 percent, and either schema/decision or CDSL
expression failures to improve.
@@ -1,82 +0,0 @@
{
"schema_version": "cdsl.author-guidance.manifest.v1",
"version": "2026-09-09.1",
"sections": [
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
{"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false},
{"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true},
{"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false},
{"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false},
{"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false},
{"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false},
{"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false},
{"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false},
{"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false},
{"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true},
{"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true},
{"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true},
{"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true},
{"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true},
{"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true},
{"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true},
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true},
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true},
{"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true},
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true},
{"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true},
{"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "priority": 100, "mandatory": true}
],
"phase_sections": {
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
"DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
"DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
"COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"],
"COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
"REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"],
"FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"],
"AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
"ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"]
},
"repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
"operation_sections": {
"extrude_add_blind": ["op-extrude-add"],
"extrude_add_blind_with_hole": ["op-extrude-add"],
"extrude_add_two_sided": ["op-extrude-add"],
"extrude_from_face": ["op-extrude-add"],
"extrude_surface": ["op-extrude-add"],
"extrude_cut_blind": ["op-extrude-cut"],
"extrude_cut_two_sided": ["op-extrude-cut"],
"extrude_cut_through": ["op-extrude-cut"],
"loft_add": ["op-loft"],
"loft_add_with_cap_face": ["op-loft"],
"revolve_add": ["op-revolve"],
"revolve_cut": ["op-revolve"],
"revolve_surface": ["op-revolve"],
"sweep_add": ["op-loft"],
"hole_blind": ["op-hole"],
"hole_counterbore": ["op-hole"],
"hole_countersink": ["op-hole"],
"hole_wizard": ["op-hole"],
"reference_plane": ["op-reference"],
"reference_axis": ["op-reference"],
"pattern_linear": ["op-pattern"],
"pattern_mirror": ["op-pattern"],
"pattern_circular": ["op-pattern"],
"fillet": ["op-finish"],
"chamfer": ["op-finish"],
"shell": ["op-finish"],
"boolean_bodies": ["op-finish"],
"transform_bodies": ["op-finish"],
"delete_bodies": ["op-finish"],
"sphere_add": ["op-sphere"],
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
"thread_add": ["op-thread"],
"thread_cut": ["op-thread"],
"bend_add": ["op-bend"],
"gear_add": ["op-gear"],
"rack_add": ["op-gear"]
}
}
@@ -1 +0,0 @@
`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm`、`width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。
@@ -1 +0,0 @@
`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。
@@ -1 +0,0 @@
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
@@ -1 +0,0 @@
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
@@ -1 +0,0 @@
`gear_add` 与 `rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm`、`teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm` 是 `z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module`。`helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。
@@ -1 +0,0 @@
孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。
@@ -1 +0,0 @@
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
@@ -1 +0,0 @@
`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。
@@ -1 +0,0 @@
`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
@@ -1 +0,0 @@
`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。
@@ -1 +0,0 @@
`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。
@@ -1 +0,0 @@
`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。
@@ -1 +0,0 @@
`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
+2 -2
View File
@@ -1,7 +1,7 @@
"""Autonomous CAD protocol v3. """Single-stage Authoring CDSL protocol.
The package intentionally separates policy from I/O. Delivery code composes The package intentionally separates policy from I/O. Delivery code composes
these modules with adapters; it must not bypass the command handlers. these modules with adapters; it must not bypass the command handlers.
""" """
PROTOCOL_VERSION = "3.0" PROTOCOL_VERSION = "cad.single-stage.v1"
+1 -1
View File
@@ -1 +1 @@
"""Infrastructure adapters for the v3 ports.""" """Infrastructure adapters for the single-stage ports."""
@@ -1,4 +1,4 @@
"""Immutable v3 file artifacts with staging manifests and atomic publication.""" """Immutable single-stage artifacts with staging manifests and atomic publication."""
from __future__ import annotations from __future__ import annotations
@@ -8,10 +8,9 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import secrets import secrets
import shutil
from typing import Any from typing import Any
from app.cad_agent.ports import CandidateStage from app.cad_agent.ports import StagingRevision
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$") _SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
@@ -27,7 +26,7 @@ class FileArtifactStore:
def task_dir(self, task_id: str) -> Path: def task_dir(self, task_id: str) -> Path:
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id): if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
raise ValueError("Invalid v3 task id") raise ValueError("Invalid CAD task id")
return self.root / task_id return self.root / task_id
def artifact_path(self, task_id: str, relative_path: str) -> Path: def artifact_path(self, task_id: str, relative_path: str) -> Path:
@@ -43,7 +42,7 @@ class FileArtifactStore:
) -> None: ) -> None:
root = self.task_dir(task_id) root = self.task_dir(task_id)
(root / "documents").mkdir(parents=True, exist_ok=True) (root / "documents").mkdir(parents=True, exist_ok=True)
(root / "actions").mkdir(exist_ok=True) (root / "events").mkdir(exist_ok=True)
(root / "revisions").mkdir(exist_ok=True) (root / "revisions").mkdir(exist_ok=True)
(root / ".staging").mkdir(exist_ok=True) (root / ".staging").mkdir(exist_ok=True)
source = root / "source-requirements.md" source = root / "source-requirements.md"
@@ -67,14 +66,14 @@ class FileArtifactStore:
if images: if images:
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images}) self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
"""Mirror committed SQLite events into an append-only JSONL audit log. """Mirror committed SQLite events into an append-only JSONL audit log.
SQLite remains authoritative. Replaying this method after an SQLite remains authoritative. Replaying this method after an
interruption appends only missing committed sequences and rejects a interruption appends only missing committed sequences and rejects a
divergent line instead of rewriting audit history. divergent line instead of rewriting audit history.
""" """
path = self._path(task_id, "actions/action-ledger.jsonl") path = self._path(task_id, "events/event-ledger.jsonl")
existing: dict[int, dict[str, Any]] = {} existing: dict[int, dict[str, Any]] = {}
if path.is_file(): if path.is_file():
for raw in path.read_text(encoding="utf-8").splitlines(): for raw in path.read_text(encoding="utf-8").splitlines():
@@ -83,18 +82,18 @@ class FileArtifactStore:
value = json.loads(raw) value = json.loads(raw)
sequence = value.get("sequence") if isinstance(value, dict) else None sequence = value.get("sequence") if isinstance(value, dict) else None
if not isinstance(sequence, int) or sequence < 1: if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger contains an invalid sequence") raise ValueError("Event ledger contains an invalid sequence")
existing[sequence] = value existing[sequence] = value
missing: list[str] = [] missing: list[str] = []
for event in events: for event in events:
sequence = event.get("sequence") sequence = event.get("sequence")
if not isinstance(sequence, int) or sequence < 1: if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger event has an invalid sequence") raise ValueError("Event ledger event has an invalid sequence")
entry = {"schema_version": "cad.action-ledger.v1", **event} entry = {"schema_version": "cad.event-ledger.v1", **event}
previous = existing.get(sequence) previous = existing.get(sequence)
if previous is not None: if previous is not None:
if previous != entry: if previous != entry:
raise ValueError("Action ledger diverges from committed SQLite event") raise ValueError("Event ledger diverges from committed SQLite event")
continue continue
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n") missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
if missing: if missing:
@@ -103,7 +102,7 @@ class FileArtifactStore:
handle.writelines(missing) handle.writelines(missing)
handle.flush() handle.flush()
os.fsync(handle.fileno()) os.fsync(handle.fileno())
return "actions/action-ledger.jsonl" return "events/event-ledger.jsonl"
def write_source_index( def write_source_index(
self, self,
@@ -136,9 +135,8 @@ class FileArtifactStore:
"""Return immutable source paragraphs and attachment blocks. """Return immutable source paragraphs and attachment blocks.
Source identifiers are assigned only after this method returns, so Source identifiers are assigned only after this method returns, so
callers cannot choose IDs. Attachment metadata is deliberately a callers cannot choose IDs. Attachment metadata is a closed,
closed, server-provided projection: it lets a reviewer trace the server-provided projection rather than arbitrary model input.
source without treating upload metadata as arbitrary LLM input.
""" """
supplied = source_blocks supplied = source_blocks
if supplied is None: if supplied is None:
@@ -195,19 +193,6 @@ class FileArtifactStore:
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file() if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
] ]
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
# State points to the immutable artifact used as program input. The
# fixed name is a read-only convenience view for terminal tasks.
return self.read_json(task_id, artifact_path or "requirements-contract.json")
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
if not invocation_id:
return self.write_json_once(task_id, "requirements-contract.json", payload)
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
path = self._path(task_id, relative_path) path = self._path(task_id, relative_path)
if not path.is_file(): if not path.is_file():
@@ -225,36 +210,20 @@ class FileArtifactStore:
self._write_once(path, text) self._write_once(path, text)
return relative_path return relative_path
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
root = self.task_dir(task_id) / ".staging" root = self.task_dir(task_id) / ".staging"
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20] stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
directory = root / stable_id directory = root / stable_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
self._write_json_once(directory / "input.json", payload) self._write_json_once(directory / "input.json", payload)
return CandidateStage(stable_id, str(directory.resolve())) return StagingRevision(stable_id, str(directory.resolve()))
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
return str(self._stage_path(task_id, stage_id, ""))
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
path = self._stage_path(task_id, stage_id, relative_path) path = self._stage_path(task_id, stage_id, relative_path)
self._write_json_once(path, payload) self._write_json_once(path, payload)
return relative_path return relative_path
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
path = self._stage_path(task_id, stage_id, relative_path)
if not path.is_file():
return None
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else None
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
source = self._stage_path(task_id, stage_id, "") source = self._stage_path(task_id, stage_id, "")
target = self._path(task_id, f"revisions/{revision_id}") target = self._path(task_id, f"revisions/{revision_id}")
if target.exists(): if target.exists():
@@ -262,9 +231,8 @@ class FileArtifactStore:
if manifest is None: if manifest is None:
raise RuntimeError("Published revision has no valid manifest") raise RuntimeError("Published revision has no valid manifest")
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
# Render/rebuild reports contain paths for the reviewer. Rebase those # Rebase report paths while artifacts are still mutable staging output,
# paths while artifacts are still mutable staging output, so they point # so they point at the revision after the atomic directory rename.
# at the revision after the atomic directory rename.
self._rebase_staged_paths(source, target) self._rebase_staged_paths(source, target)
manifest = self._create_manifest(source) manifest = self._create_manifest(source)
self._write_json_once(source / "manifest.json", manifest) self._write_json_once(source / "manifest.json", manifest)
@@ -272,39 +240,6 @@ class FileArtifactStore:
os.replace(source, target) os.replace(source, target)
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
"""Find a manifest-verified candidate already renamed before its DB CAS.
Publishing artifacts and advancing SQLite cannot share a transaction.
The stage id persisted inside ``candidate.json`` makes a post-rename
recovery deterministic and prevents another build or revision.
"""
revisions = self.task_dir(task_id) / "revisions"
if not revisions.is_dir():
return None
for revision in sorted(revisions.iterdir()):
if not revision.is_dir() or self._manifest(revision) is None:
continue
candidate_path = revision / "candidate.json"
if not candidate_path.is_file():
continue
try:
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
return revision.name, candidate
return None
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
root = self.task_dir(task_id) / ".staging"
if not root.is_dir():
return
for directory in root.iterdir():
if not directory.is_dir() or directory.name in referenced_stage_ids:
continue
shutil.rmtree(directory)
def _path(self, task_id: str, relative_path: str) -> Path: def _path(self, task_id: str, relative_path: str) -> Path:
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts): if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
raise ValueError("Invalid artifact relative path") raise ValueError("Invalid artifact relative path")
@@ -316,7 +251,7 @@ class FileArtifactStore:
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path: def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id): if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
raise ValueError("Invalid candidate stage id") raise ValueError("Invalid staging revision id")
root = self.task_dir(task_id).resolve() root = self.task_dir(task_id).resolve()
stage = (root / ".staging" / stage_id).resolve() stage = (root / ".staging" / stage_id).resolve()
if root not in stage.parents: if root not in stage.parents:
@@ -351,13 +286,6 @@ class FileArtifactStore:
temporary.write_bytes(data) temporary.write_bytes(data)
os.replace(temporary, path) os.replace(temporary, path)
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
if not safe_invocation:
raise ValueError("Artifact invocation ID is invalid")
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
@classmethod @classmethod
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None: def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n") cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
@@ -371,8 +299,8 @@ class FileArtifactStore:
relative = path.relative_to(directory).as_posix() relative = path.relative_to(directory).as_posix()
files[relative] = sha256(path.read_bytes()).hexdigest() files[relative] = sha256(path.read_bytes()).hexdigest()
if not files: if not files:
raise RuntimeError("Candidate staging directory has no artifacts") raise RuntimeError("Staging revision has no artifacts")
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files} return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
@staticmethod @staticmethod
def _manifest(directory: Path) -> dict[str, Any] | None: def _manifest(directory: Path) -> dict[str, Any] | None:
@@ -1,191 +0,0 @@
"""Bounded, file-backed author guidance for the CDSL workflow.
The corpus is deliberately non-authoritative: contracts, schemas, topology
tokens, and server preflight always remain the executable source of truth.
Loading errors return an empty selection so authoring continues with the
pre-guidance prompt instead of turning documentation into an availability
dependency.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.cad_agent.domain.state import TaskPhase
from app.cad_agent.ports import AuthorGuidanceSelection
_MIN_CHARS = 1_200
_MAX_CHARS = 6_000
@dataclass(frozen=True, slots=True)
class _Section:
section_id: str
title: str
priority: int
mandatory: bool
content: str
@property
def block(self) -> str:
return f"## {self.title}\n{self.content.strip()}"
@dataclass(frozen=True, slots=True)
class _Corpus:
version: str
sections: dict[str, _Section]
phase_sections: dict[str, tuple[str, ...]]
repair_sections: tuple[str, ...]
final_sections: tuple[str, ...]
operation_sections: dict[str, tuple[str, ...]]
class FileAuthorGuidance:
"""Read and select the checked-in corpus deterministically.
Selection depends exclusively on workflow state and the runtime operation
registry. It intentionally receives neither the user's request nor image
observations, so it cannot become an implicit part-family classifier.
"""
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
self.root = root
self.enabled = enabled
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
self._corpus: _Corpus | None = None
self._load_error = ""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection:
if not self.enabled:
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
corpus = self._load()
if corpus is None:
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
supported = set(supported_atomic_ids)
if set(corpus.operation_sections) != supported:
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
if atomic_id and atomic_id not in supported:
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
if repair_required:
requested = list(corpus.repair_sections)
elif phase == TaskPhase.FINAL_VALIDATION:
requested = list(corpus.final_sections)
else:
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
if atomic_id:
requested.extend(corpus.operation_sections[atomic_id])
requested = list(dict.fromkeys(requested))
if not requested:
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
selected: list[str] = []
text = ""
for section_id in [*mandatory, *optional]:
block = corpus.sections[section_id].block
candidate = block if not text else f"{text}\n\n{block}"
if len(candidate) <= self.max_chars:
text = candidate
selected.append(section_id)
elif section_id in mandatory:
# Do not silently drop contract, datum, or operation guidance.
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
return AuthorGuidanceSelection(
version=corpus.version,
section_ids=tuple(selected),
content=text,
enabled=True,
)
def _load(self) -> _Corpus | None:
if self._corpus is not None:
return self._corpus
if self._load_error:
return None
try:
manifest_path = self.root / "manifest.json"
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("manifest is not an object")
version = raw.get("version")
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
raise ValueError("manifest version is invalid")
raw_sections = raw.get("sections")
if not isinstance(raw_sections, list) or not raw_sections:
raise ValueError("manifest sections are invalid")
sections: dict[str, _Section] = {}
root = self.root.resolve()
for item in raw_sections:
if not isinstance(item, dict):
raise ValueError("section declaration is invalid")
section_id = item.get("id")
filename = item.get("file")
title = item.get("title")
priority = item.get("priority")
mandatory = item.get("mandatory", False)
if (
not isinstance(section_id, str) or not section_id
or not isinstance(filename, str) or not filename
or not isinstance(title, str) or not title
or not isinstance(priority, int) or isinstance(priority, bool)
or not isinstance(mandatory, bool)
or section_id in sections
):
raise ValueError("section metadata is invalid")
path = (self.root / filename).resolve()
if root not in path.parents or not path.is_file():
raise ValueError("section file is unavailable")
content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError("section content is empty")
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
def identifiers(value: Any, field: str) -> tuple[str, ...]:
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
raise ValueError(f"{field} is invalid")
return tuple(dict.fromkeys(value))
raw_phases = raw.get("phase_sections")
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
raise ValueError("phase sections are invalid")
phase_sections = {
phase: identifiers(section_ids, f"phase {phase}")
for phase, section_ids in raw_phases.items()
if isinstance(phase, str)
}
if len(phase_sections) != len(raw_phases):
raise ValueError("phase name is invalid")
operation_sections = {
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
if isinstance(atomic_id, str)
}
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
raise ValueError("operation sections are invalid")
self._corpus = _Corpus(
version=version,
sections=sections,
phase_sections=phase_sections,
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
final_sections=identifiers(raw.get("final_sections"), "final sections"),
operation_sections=operation_sections,
)
return self._corpus
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
self._load_error = f"guidance_load_failed:{type(error).__name__}"
return None
@@ -1,4 +1,4 @@
"""In-process idempotent event delivery for the v3 delivery boundary.""" """In-process idempotent event delivery for the single-stage protocol."""
from __future__ import annotations from __future__ import annotations
@@ -1,64 +0,0 @@
"""Independent rendered-review adapter for protocol v3."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Any
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
from app.cad_agent.ports import AdapterUnavailable
class RenderedReviewGateway:
def __init__(self, models: StructuredModelGateway) -> None:
self.models = models
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
name = str((tool.get("function") or {}).get("name") or "")
if not name:
raise RuntimeError("Review tool is missing a name")
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
if kind in {"image_observation", "final"}:
for raw_path in payload.get("reference_image_paths") or ():
path = Path(str(raw_path))
if path.is_file():
content.append(self._image_part(path))
if kind in {"candidate", "final"}:
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
for path in self._evidence_paths(manifest):
content.append(self._image_part(path))
return await self.models.call_tool(
messages=[
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
{"role": "user", "content": content},
],
tool=tool,
provider_id=provider_id,
model_id=model_id,
required_tool_name=name,
)
@staticmethod
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
selected: list[Path] = []
contact = Path(str(manifest.get("contact_sheet_path") or ""))
if contact.is_file():
selected.append(contact)
wanted = {"top", "front", "right", "isometric"}
for item in manifest.get("views") or ():
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
continue
path = Path(str(item.get("path") or ""))
if path.is_file():
selected.append(path)
if not selected:
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
return selected[:5]
@staticmethod
def _image_part(path: Path) -> dict[str, Any]:
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,8 @@
"""SQLite repository: the sole mutable state authority for protocol v3.""" """SQLite authority for ``cad.single-stage.v1`` tasks.
There is no reader or migration for the deleted feature-DAG protocol. Opening
a legacy task database drops its task data before creating this schema.
"""
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
@@ -10,11 +13,13 @@ from threading import RLock
from typing import Any, Iterator from typing import Any, Iterator
from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState from app.cad_agent.domain.state import TaskPhase, TaskState
from app.cad_agent.ports import InvocationRecord from app.cad_agent.ports import InvocationRecord
class SqliteTaskRepository: class SqliteTaskRepository:
PROTOCOL_VERSION = "cad.single-stage.v1"
def __init__(self, database_path: Path) -> None: def __init__(self, database_path: Path) -> None:
self.database_path = database_path self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True) self.database_path.parent.mkdir(parents=True, exist_ok=True)
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
def _initialize(self) -> None: def _initialize(self) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
# Protocol 3.2 intentionally has no migration path from the existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
# structured-only / review-loop task model. Deployment starts with if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""):
# 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.2'" not in str(existing[0] or ""):
self.protocol_reset = True self.protocol_reset = True
connection.executescript(""" # This database is dedicated to CAD task state. An old
DROP TABLE IF EXISTS outbox; # protocol has no safe reader or migration, so remove every
DROP TABLE IF EXISTS tool_audits; # persisted task table and let SQLite remove their indexes.
DROP TABLE IF EXISTS usage_records; tables = connection.execute(
DROP TABLE IF EXISTS invocations; "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
DROP TABLE IF EXISTS ledger; ).fetchall()
DROP TABLE IF EXISTS model_capabilities; for row in tables:
DROP TABLE IF EXISTS tasks; name = str(row[0]).replace('"', '""')
""") connection.execute(f'DROP TABLE IF EXISTS "{name}"')
connection.executescript( connection.executescript(f"""
"""
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY, task_id TEXT PRIMARY KEY,
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'), protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
request TEXT NOT NULL, request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
phase TEXT NOT NULL, active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
state_version INTEGER NOT NULL, last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
active_revision TEXT NOT NULL DEFAULT '', requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
pending_action_json TEXT, runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
candidate_id TEXT NOT NULL DEFAULT '', diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
candidate_stage_id TEXT NOT NULL DEFAULT '',
repair_required INTEGER NOT NULL DEFAULT 0,
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 '',
feature_plan_path TEXT NOT NULL DEFAULT '',
feature_plan_hash TEXT NOT NULL DEFAULT '',
feature_stage_id TEXT NOT NULL DEFAULT '',
clarification_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, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS invocations ( CREATE TABLE IF NOT EXISTS invocations (
invocation_id TEXT PRIMARY KEY, invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
task_id TEXT NOT NULL REFERENCES tasks(task_id), idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
idempotency_key TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL, UNIQUE(task_id,idempotency_key)
result_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(task_id, idempotency_key)
); );
CREATE TABLE IF NOT EXISTS ledger ( CREATE TABLE IF NOT EXISTS ledger (
sequence INTEGER PRIMARY KEY AUTOINCREMENT, sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
task_id TEXT NOT NULL REFERENCES tasks(task_id), event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
event_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS outbox ( CREATE TABLE IF NOT EXISTS outbox (
event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
task_id TEXT NOT NULL REFERENCES tasks(task_id), event_json TEXT NOT NULL, published_at TEXT
event_json TEXT NOT NULL,
published_at TEXT
); );
CREATE TABLE IF NOT EXISTS usage_records ( CREATE TABLE IF NOT EXISTS usage_records (
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
task_id TEXT NOT NULL REFERENCES tasks(task_id), usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
usage_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS tool_audits ( CREATE TABLE IF NOT EXISTS tool_audits (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
task_id TEXT NOT NULL REFERENCES tasks(task_id), audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
audit_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS model_capabilities ( CREATE TABLE IF NOT EXISTS model_capabilities (
provider_id TEXT NOT NULL, provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
model_id TEXT NOT NULL, supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
schema_hash TEXT NOT NULL, PRIMARY KEY(provider_id,model_id,schema_hash)
supported INTEGER NOT NULL,
report_json TEXT NOT NULL,
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(provider_id, model_id, schema_hash)
); );
""" """)
)
def create_task(self, task_id: str, request: str) -> TaskState: def create_task(self, task_id: str, request: str) -> TaskState:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute( connection.execute(
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)", "INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value), (task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
) )
state = self.get_state(task_id) state = self.get_state(task_id)
if state is None: if state is None:
raise RuntimeError("SQLite task insert was not visible") raise RuntimeError("Task was not persisted")
return state return state
def get_state(self, task_id: str) -> TaskState | None: def get_state(self, task_id: str) -> TaskState | None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
return self._state(row) if row is not None else None return self._state(row) if row else None
def get_task_projection(self, task_id: str) -> dict[str, Any] | None: def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
state = self.get_state(task_id) state = self.get_state(task_id)
if state is None: if state is None:
return None return None
events = self.ledger_events(task_id) events = self.ledger_events(task_id)
revisions = [ # A later repair can end at authoring or compilation after an earlier
{ # build already published an executable prefix. Its terminal event
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint", # intentionally contains diagnostics rather than a duplicate path
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""), # map, so keep the most recent event that actually delivered a
} # revision. Otherwise the completed projection loses the existing
for item in events # STEP/GLB links exactly when best-effort publication matters most.
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str) result: dict[str, Any] = {}
] paths: dict[str, Any] = {}
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {}) for item in reversed(events):
verification_warnings = [ if item.get("event") not in {
str(item) for item in frozen.get("verification_warnings") or () if str(item) "build_completed", "published_best_effort", "build_failed",
] if isinstance(frozen, dict) else [] }:
status_event = next(( continue
item for item in reversed(events) candidate_paths = item.get("paths")
if item.get("event") in { if isinstance(candidate_paths, dict) and candidate_paths:
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted", result, paths = item, candidate_paths
"no_progress_limit", break
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
"failed_author_format", "runtime_contract_invalid",
"completed_best_effort",
}
), {}) 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 { return {
"schema_version": "3.2", "schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
"task_id": state.task_id, "lifecycle": self._lifecycle(state.phase), "state_version": state.version,
"phase": state.phase.value, "active_revision": state.active_revision, "current_revision": state.active_revision,
"lifecycle": self._lifecycle(state.phase),
"state_version": state.version,
"active_revision": state.active_revision,
"current_revision": state.active_revision,
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "", "published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
"pending_action": self._pending_payload(state.pending_action), "repair_count": state.repair_count, "repair_budget": 2,
"active_candidate_id": state.candidate_id,
"repair_required": state.repair_required,
"last_error": state.last_error.value if state.last_error else "", "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_path": state.requirements_path, "authoring_path": state.authoring_path,
"requirements_spec_path": state.requirements_spec_path, "runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
"requirements_document_path": state.requirements_document_path, "diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
"completion_target_path": state.completion_target_path, "message": str(result.get("message") or ""),
"modeling_plan_path": state.modeling_plan_path, "revisions": ([{
"feature_plan_path": state.feature_plan_path, "revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
"feature_plan_hash": state.feature_plan_hash, "cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "", "glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
"pending_feature": self._pending_payload(state.pending_feature), }] if state.active_revision else []),
"feature_nodes": self._feature_node_projection(events), "action_ledger_summary": events[-16:],
"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 or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
else "verified" if state.phase == TaskPhase.COMPLETED
else "pending"
),
"verification_warnings": verification_warnings,
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
"questions": questions,
"issues": issues,
"blocker_type": (
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
),
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
"action_ledger_summary": events[-12:],
"revisions": revisions,
} }
@staticmethod
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
nodes: dict[str, dict[str, Any]] = {}
for event in events:
node_id = str(event.get("node_id") or "")
if not node_id:
continue
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
if event.get("event") == "feature_node_scheduled":
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
elif event.get("event") == "feature_node_failed":
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
elif event.get("event") == "feature_node_verified":
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
item["status"] = "invalidated"
return list(nodes.values())
def ledger_events(self, task_id: str) -> list[dict[str, Any]]: def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall() rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows] return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
def invocation_records(self, task_id: str) -> list[dict[str, Any]]: def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
with self._lock, self._connection() as connection:
rows = connection.execute(
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
(task_id,),
).fetchall()
return [
{
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
}
for row in rows
]
def compare_and_swap(
self,
state: TaskState,
*,
events: list[dict[str, Any]] = (),
invocation_id: str | None = None,
invocation_result: dict[str, Any] | None = None,
) -> bool:
if (invocation_id is None) != (invocation_result is None): if (invocation_id is None) != (invocation_result is None):
raise ValueError("Invocation completion requires both invocation_id and invocation_result") raise ValueError("Invocation completion requires both values")
previous_version = state.version - 1 previous = state.version - 1
if previous_version < 0:
raise ValueError("State version must advance exactly once")
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
try: try:
cursor = connection.execute( cursor = connection.execute("""
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?, UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?, requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id=? AND state_version=?
WHERE task_id = ? AND state_version = ?""", """, (
( state.phase.value, state.version, state.active_revision, state.repair_count,
state.phase.value, state.version, state.active_revision, state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None, state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
state.candidate_id, state.candidate_stage_id, state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
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.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
state.clarification_path, state.requirements_contract_path,
state.task_id, previous_version,
),
)
if cursor.rowcount != 1: if cursor.rowcount != 1:
connection.execute("ROLLBACK") connection.execute("ROLLBACK")
return False return False
for event in events: for item in events:
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True) encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
if invocation_id is not None and invocation_result is not None: if invocation_id is not None:
finished = connection.execute( updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
"""UPDATE invocations if updated.rowcount != 1:
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP raise ValueError("Invocation is not active")
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
)
if finished.rowcount != 1:
raise ValueError("Invocation is not an active record for this state transition")
connection.execute("COMMIT") connection.execute("COMMIT")
return True return True
except Exception: except Exception:
@@ -310,144 +195,92 @@ class SqliteTaskRepository:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
try: try:
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone() row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
if existing is not None: if row is None:
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
connection.execute("COMMIT") connection.execute("COMMIT")
return self._invocation(existing) return InvocationRecord(invocation_id, idempotency_key, "processing")
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
connection.execute("COMMIT") connection.execute("COMMIT")
return InvocationRecord(invocation_id, idempotency_key, "processing") return self._invocation(row)
except Exception: except Exception:
connection.execute("ROLLBACK") connection.execute("ROLLBACK")
raise raise
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None: def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
row = connection.execute( row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?", return self._invocation(row) if row else None
(task_id, invocation_id),
).fetchone()
return self._invocation(row) if row is not None else None
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None: def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone() connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
if row is None:
raise ValueError("Unknown invocation")
if str(row["status"]) == "finished":
# A winning concurrent command has already recorded the only
# durable result for this idempotency key. Never overwrite it.
return
cursor = connection.execute(
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
(json.dumps(result, ensure_ascii=True), invocation_id),
)
if cursor.rowcount != 1:
raise ValueError("Invocation could not be completed")
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None: def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True))) connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]: def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
args: tuple[Any, ...] = ()
if task_id is not None:
query += " AND task_id=?"
args += (task_id,)
query += " ORDER BY event_id LIMIT ?"
args += (limit,)
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
if task_id is None: rows = connection.execute(query, args).fetchall()
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall() return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
else:
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
def mark_outbox_published(self, event_id: int) -> None: def mark_outbox_published(self, event_id: int) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,)) connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,))
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None: def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None: def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
"""Persist a diagnostic structured-output audit separately from usage."""
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute( connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
)
def tool_audits(self, task_id: str) -> list[dict[str, Any]]: def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
rows = connection.execute( rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id", return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
(task_id,),
).fetchall()
return [
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
for row in rows
]
def usage_summary(self, task_id: str) -> dict[str, Any]: def usage_summary(self, task_id: str) -> dict[str, Any]:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall() rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
values = [json.loads(row["usage_json"]) for row in rows] return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
return {
"calls": len(values),
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
"records": values,
}
def running_task_ids(self) -> list[str]: def running_task_ids(self) -> list[str]:
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY}) terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
placeholders = ", ".join("?" for _ in phases)
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall() rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
return [str(row["task_id"]) for row in rows] return [str(row["task_id"]) for row in rows]
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None: def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone() row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None: def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
with self._lock, self._connection() as connection: with self._lock, self._connection() as connection:
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json) connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
@staticmethod @staticmethod
def _state(row: sqlite3.Row) -> TaskState: def _state(row: sqlite3.Row) -> TaskState:
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
pending = PendingAction(
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
) if isinstance(raw_pending, dict) else None
return TaskState( return TaskState(
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]), task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
active_revision=str(row["active_revision"] or ""), pending_action=pending, active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
repair_required=bool(row["repair_required"]),
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None, 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, 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_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
requirements_document_path=str(row["requirements_document_path"] or ""), runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
completion_target_path=str(row["completion_target_path"] or ""), diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
modeling_plan_path=str(row["modeling_plan_path"] or ""),
feature_plan_path=str(row["feature_plan_path"] or ""),
feature_plan_hash=str(row["feature_plan_hash"] or ""),
feature_stage_id=str(row["feature_stage_id"] or ""),
clarification_path=str(row["clarification_path"] or ""),
requirements_contract_path=str(row["requirements_contract_path"] or ""),
) )
@staticmethod
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
if pending is None:
return None
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
@staticmethod @staticmethod
def _invocation(row: sqlite3.Row) -> InvocationRecord: def _invocation(row: sqlite3.Row) -> InvocationRecord:
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None) return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
return "failed" return "failed"
if phase == TaskPhase.CANCELLED: if phase == TaskPhase.CANCELLED:
return "cancelled" return "cancelled"
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: if phase == TaskPhase.WAITING_FOR_USER:
return phase.value.lower() return "waiting_for_user"
return "running" return "running"
@@ -1,4 +1,4 @@
"""The sole OpenAI-compatible structured-output adapter for protocol v3. """The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
Raw tool arguments are intentionally preserved. Callers must run their Raw tool arguments are intentionally preserved. Callers must run their
Pydantic/JSON-Schema canonical validator before causing any state transition. Pydantic/JSON-Schema canonical validator before causing any state transition.
@@ -170,39 +170,12 @@ class StructuredModelGateway:
@staticmethod @staticmethod
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]: def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
"""Give capability probes the same selector fact production exposes. """Probe the two standalone structured documents used by this protocol."""
A required opaque selector is not inferable from a generic request for
a minimal example. Production fragment turns expose a topology token
before the fragment tool, so the probe must do the same while still
letting the provider generate every other schema field itself.
"""
function = tool.get("function") if isinstance(tool.get("function"), dict) else {} function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
name = str(function.get("name") or "") name = str(function.get("name") or "")
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative." instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
if selector_required: if name == "write_authoring_cdsl":
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..." instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
instruction += (
" The current topology exposes opaque selector token `sel_conformance`. "
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
)
if name == "review_candidate":
instruction += (
" This is a candidate-review probe: include every required top-level and nested field; "
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
)
elif name == "review_final":
instruction += (
" This is a final-review probe: include every required top-level and nested field; "
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
)
return [{"role": "system", "content": instruction}] return [{"role": "system", "content": instruction}]
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]: def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
@@ -256,7 +229,7 @@ class StructuredModelGateway:
force_tool_name: bool = True, force_tool_name: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
if len(tools) != 1 or not required_tool_name: if len(tools) != 1 or not required_tool_name:
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.") raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.")
request_options = provider.request_options if include_reasoning else {} request_options = provider.request_options if include_reasoning else {}
if provider.api_style == "responses": if provider.api_style == "responses":
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -1,19 +0,0 @@
"""Adapter exposing the pure verifier registry through the application port."""
from __future__ import annotations
from typing import Any
from app.cad_agent.domain.verifier_registry import VerifierRegistry
class RegistryVerifierExecutor:
def __init__(self, registry: VerifierRegistry) -> None:
self.registry = registry
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for claim in claims:
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
return results
@@ -1 +1 @@
"""Application command handlers and context assembly for protocol v3.""" """Application workflow and compiler for the Authoring CDSL protocol."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any, Callable
from jsonschema import Draft202012Validator
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
class AuthoringCompileError(ValueError):
def __init__(self, code: str, message: str, *, path: str = "") -> None:
self.code, self.path = code, path
super().__init__(message)
class AuthoringCompiler:
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
self.operation_contract = operation_contract
self.version = version
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
try:
validate_finite(raw)
doc = AuthoringDocument.model_validate(raw)
except Exception as error:
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
source_features = [feature for body in doc.bodies for feature in body.features]
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
ordered = self._topological(doc, dependencies)
# Identity follows the source document, while execution follows the
# dependency graph. A dependency reordering must never renumber IDs.
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
features: list[dict[str, Any]] = []
sketches: list[dict[str, Any]] = []
sketch_index = 0
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
sketch_ids_by_feature = {
feature.name: f"sketch_{source_positions[feature.name]:03d}"
for feature in source_features
if feature.sketch is not None
}
for feature in ordered:
try:
contract = self.operation_contract(feature.operation)
except Exception as error:
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
params = self._references(
deepcopy(feature.params), contract, feature_ids, feature.name,
sketch_ids_by_feature=sketch_ids_by_feature,
)
self._validate_params(contract, params, feature.name)
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
required_selectors = fragment_shape["selector_tokens"] == "required"
runtime_feature_selectors: list[dict[str, Any]] = []
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
if not required_selectors and selectors:
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
if required_selectors:
slot = str(selector_policy["slot"])
if slot == "feature.selectors":
runtime_feature_selectors = selectors
elif slot.startswith("params."):
parameter = slot.removeprefix("params.")
if "." in parameter:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
if parameter in params:
raise AuthoringCompileError(
"AUTHOR_FORBIDDEN_FIELD",
f"{parameter} is server-injected from selectors",
path=f"features.{feature.name}.params.{parameter}",
)
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
else:
raise AuthoringCompileError(
"OPERATION_UNSUPPORTED",
f"runtime has no safe selector binding for {slot}",
path=f"features.{feature.name}.selectors",
)
output = {
"id": feature_ids[feature.name], "atomic_id": feature.operation,
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
"params": params,
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
}
needs_sketch = fragment_shape["sketch"] == "required"
if needs_sketch and feature.sketch is None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
if not needs_sketch and feature.sketch is not None:
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
if feature.sketch is not None:
sketch_index += 1
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
output["sketch_id"] = sketch_id
features.append(output)
runtime = {
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
"part_id": "compiled", "meta": {"unit": "mm"},
"bodies": [
{"id": body_ids[body.name], "name": body.name}
for body in doc.bodies
],
"geometry": {"sketches": sketches}, "features": features,
}
digest = sha256(self._canonical_json(raw)).hexdigest()
return runtime, {
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
"sketch_ids": [item["id"] for item in sketches],
"implicit_selector_dependencies": implicit_selector_dependencies,
}
@staticmethod
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Make every declared selector source an auditable graph dependency.
A selector is already an explicit local source reference. Requiring the
author to repeat that same edge in a second field only creates a
formatting failure; it does not add CAD intent. The compiler therefore
adds the direct source edge deterministically and records it in the
audit. It never selects a substitute topology element.
"""
by_name = {item.name: item for body in doc.bodies for item in body.features}
dependencies: dict[str, list[str]] = {}
implicit: dict[str, list[str]] = {}
for feature in by_name.values():
values = list(feature.depends_on)
additions: list[str] = []
for selector in feature.selectors:
source = selector.source.split(".", 1)[0]
if source not in by_name:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID",
f"unknown selector source: {selector.source}",
path=f"features.{feature.name}.selectors",
)
if source not in values:
values.append(source)
additions.append(source)
dependencies[feature.name] = values
if additions:
implicit[feature.name] = additions
return dependencies, implicit
@staticmethod
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
by_name = {item.name: item for body in doc.bodies for item in body.features}
result: list[Any] = []
visiting: set[str] = set()
done: set[str] = set()
def visit(name: str) -> None:
if name in visiting:
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
if name in done:
return
visiting.add(name)
for dependency in dependencies[name]:
visit(dependency)
visiting.remove(name); done.add(name); result.append(by_name[name])
for body in doc.bodies:
for feature in body.features:
visit(feature.name)
return result
@staticmethod
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
source = selector.source.split(".", 1)[0]
if source not in feature_ids:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
source_feature = next(item for item in source_features if item.name == source)
role = selector.source.split(".", 1)[1]
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
@staticmethod
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
"""Lower the small Authoring sketch language to the generic runtime form."""
profile = sketch["profile"]
if profile["type"] == "circle":
profile = {
"type": "circle",
"center": profile["center_mm"],
"radius_mm": float(profile["diameter_mm"]) / 2.0,
}
return {"workplane": sketch["workplane"], "profile": profile}
@staticmethod
def _runtime_role(role: str, operation: str) -> str:
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
return role
family = (
"extrude" if operation.startswith("extrude") else
"sweep" if operation.startswith("sweep") else
"loft" if operation.startswith("loft") else
"cylinder" if operation == "cylinder_add" else ""
)
if role in {"top_planar_face", "end_face"} and family:
return family + ".end"
if role in {"bottom_planar_face", "start_face"} and family:
return family + ".start"
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
@staticmethod
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
schema = contract.get("author_params_schema") or {"type": "object"}
errors = list(Draft202012Validator(schema).iter_errors(params))
if errors:
first = errors[0]
path = ".".join(str(item) for item in first.absolute_path)
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
@staticmethod
def _references(
params: dict[str, Any],
contract: dict[str, Any],
feature_ids: dict[str, str],
feature_name: str,
*,
sketch_ids_by_feature: dict[str, str],
) -> dict[str, Any]:
params = AuthoringCompiler._rewrite_named_references(
params, feature_ids, sketch_ids_by_feature, feature_name,
)
policy = contract.get("reference_policy") or {"mode": "none"}
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
return params
key = str(policy["slot"]).removeprefix("params.")
value = params.get(key)
if isinstance(value, list):
names = value
elif value is None and policy["min_items"] == 0:
names = []
elif isinstance(value, str):
names = [value]
else:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
runtime_ids = set(feature_ids.values())
if not all(isinstance(item, str) and item in runtime_ids for item in names):
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
if not policy["min_items"] <= len(names) <= policy["max_items"]:
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
return params
@staticmethod
def _rewrite_named_references(
value: Any,
feature_ids: dict[str, str],
sketch_ids_by_feature: dict[str, str],
consuming_feature: str,
key: str = "",
) -> Any:
if isinstance(value, dict):
return {
item_key: AuthoringCompiler._rewrite_named_references(
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
)
for item_key, item_value in value.items()
}
if isinstance(value, list):
return [
AuthoringCompiler._rewrite_named_references(
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
)
for item in value
]
if not isinstance(value, str):
return value
if key == "profile_sketch_ids":
if value not in sketch_ids_by_feature:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return sketch_ids_by_feature[value]
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
if value not in feature_ids:
raise AuthoringCompileError(
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
path=f"features.{consuming_feature}.params.{key}",
)
return feature_ids[value]
return value
@staticmethod
def _canonical_json(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
@@ -0,0 +1,169 @@
"""Strict model-facing Authoring CDSL contract.
This is intentionally separate from the runtime CDSL: model output contains
only document-local names and declarative references. Runtime identities are
allocated by :mod:`authoring_compiler`.
"""
from __future__ import annotations
import math
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
_FORBIDDEN = {
"id",
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
"host_face", "mirror_plane",
}
def validation_error_code(error: Exception) -> str:
"""Map strict model validation failures to a stable public diagnostic."""
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
class AuthorModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@model_validator(mode="before")
@classmethod
def reject_internal_fields(cls, value: Any) -> Any:
if isinstance(value, dict):
found = sorted(
key for key in value
if isinstance(key, str)
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
)
if found:
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
for nested in value.values():
cls.reject_internal_fields(nested)
return value
if isinstance(value, list):
for item in value:
cls.reject_internal_fields(item)
return value
class AuthorWorkplane(AuthorModel):
"""A fully explicit local sketch frame in world millimetres."""
origin_mm: list[float] = Field(min_length=3, max_length=3)
x_dir: list[float] = Field(min_length=3, max_length=3)
normal: list[float] = Field(min_length=3, max_length=3)
class AuthorCircleProfile(AuthorModel):
"""A declarative circle expressed with the user-facing diameter."""
type: Literal["circle"]
diameter_mm: float = Field(gt=0)
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
class AuthorPolygonProfile(AuthorModel):
"""A closed polygon in the local sketch workplane."""
type: Literal["polygon"]
vertices: list[list[float]] = Field(min_length=3)
@field_validator("vertices")
@classmethod
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
if any(len(point) != 2 for point in value):
raise ValueError("polygon vertices must have exactly two coordinates")
return value
class AuthorSketch(AuthorModel):
"""The only authoring sketch form currently accepted by the compiler."""
workplane: AuthorWorkplane
profile: AuthorCircleProfile | AuthorPolygonProfile
class SelectorIntent(AuthorModel):
"""A local feature-output reference, never a Runtime selector token."""
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
source: str = Field(
min_length=3,
max_length=160,
description="A local feature output in the form <feature_name>.<output_role>.",
)
match: Literal["unique", "all"] = "unique"
@field_validator("source")
@classmethod
def require_feature_output_reference(cls, value: str) -> str:
feature, separator, role = value.partition(".")
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
raise ValueError("selector source must be <feature_name>.<output_role>")
return value
class AuthorFeature(AuthorModel):
name: str = Field(pattern=_NAME)
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
params: dict[str, Any] = Field(
default_factory=dict,
description="Only parameters from this feature operation's supplied params_schema.",
)
depends_on: list[str] = Field(default_factory=list, max_length=32)
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
sketch: AuthorSketch | None = Field(
default=None,
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
)
class AuthorBody(AuthorModel):
name: str = Field(pattern=_NAME)
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
class AuthoringDocument(AuthorModel):
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
units: str = Field(default="mm", pattern=r"^mm$")
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
assumptions: list[str] = Field(default_factory=list, max_length=64)
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def validate_symbols(self) -> "AuthoringDocument":
validate_finite(self.model_dump(mode="python"))
bodies = [b.name for b in self.bodies]
if len(bodies) != len(set(bodies)):
raise ValueError("duplicate body name")
names: set[str] = set()
for body in self.bodies:
for feature in body.features:
if feature.name in names:
raise ValueError(f"duplicate feature name: {feature.name}")
names.add(feature.name)
for body in self.bodies:
for feature in body.features:
if len(feature.depends_on) != len(set(feature.depends_on)):
raise ValueError(f"duplicate dependency: {feature.name}")
if any(dep not in names for dep in feature.depends_on):
missing = next(dep for dep in feature.depends_on if dep not in names)
raise ValueError(f"unknown feature reference: {missing}")
return self
def validate_finite(value: Any, path: str = "$") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise ValueError(f"non-finite number at {path}")
if isinstance(value, dict):
for key, item in value.items():
validate_finite(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
validate_finite(item, f"{path}[{index}]")
@@ -0,0 +1,16 @@
"""Prompt material for the model-facing Authoring CDSL contract."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
@lru_cache(maxsize=1)
def load_authoring_guidance() -> str:
try:
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
except OSError:
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
@@ -6,62 +6,25 @@ from hashlib import sha256
import json import json
from typing import Any, Literal from typing import Any, Literal
from app.cad_agent.application.llm_contracts import ( from app.cad_agent.application.authoring_contract import AuthoringDocument
EmptyCommand, from app.cad_agent.application.workflow import RequirementsAnalysis
ImageObservation,
MarkdownDocument,
StatelessCandidateReview,
StatelessGeometryConclusion,
StatelessRollbackCheckpoint,
StatelessTopologyRequest,
compiled_requirements_schema,
stateless_final_review_schema,
stateless_next_action_schema,
)
from app.cad_agent.domain.feature_plan import FeaturePlan
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.ports import CadRuntime, ModelGateway from app.cad_agent.ports import CadRuntime, ModelGateway
CapabilityRole = Literal["author", "reviewer"] CapabilityRole = Literal["author"]
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]: def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
if role == "reviewer": if not runtime.supported_atomic_ids():
return [
_tool("observe_images", ImageObservation.model_json_schema()),
# Compatibility conformance probe; regular DAG execution never
# calls a per-node reviewer.
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
_tool("review_final", stateless_final_review_schema(1)),
]
atomic_ids = list(runtime.supported_atomic_ids())
if not atomic_ids:
raise RuntimeError("Runtime has no operations for conformance") raise RuntimeError("Runtime has no operations for conformance")
tools = [ return [
_tool("write_requirements_document", MarkdownDocument.model_json_schema()), _tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()), _tool("write_authoring_cdsl", AuthoringDocument.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_feature_plan", FeaturePlan.model_json_schema()),
# Compatibility probe only; production v3.2 workflow never exposes it.
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
_tool("complete_task", EmptyCommand.model_json_schema()),
] ]
for atomic_id in atomic_ids:
contract = runtime.operation_contract(atomic_id)
tools.append(_tool(
f"conformance_{atomic_id}",
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
))
return tools
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str: def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools} payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -1,412 +0,0 @@
"""Canonical schemas for every v3 LLM state-changing command.
Providers may parse structured output first, but this module validates the raw
tool arguments a second time before a command reaches a handler.
"""
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
import math
from typing import Annotated, Any, Literal, TypeVar
from jsonschema import Draft202012Validator
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
class StrictDto(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
class AcceptanceClaimInput(StrictDto):
claim_kind: Identifier
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
class SpecRequirementInput(StrictDto):
statement: Annotated[str, Field(min_length=1, max_length=1000)]
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 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)]
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
class RequirementsClarification(StrictDto):
outcome: Literal["clarification"]
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
question: Annotated[str, Field(min_length=1, max_length=500)]
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
pass
class EmptyCommand(StrictDto):
pass
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
# 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
@model_validator(mode="after")
def _requirement_ids_are_unique(self) -> "NextAction":
if len(self.requirement_ids) != len(set(self.requirement_ids)):
raise ValueError("requirement_ids must not contain duplicates")
return self
class StatelessNextAction(StrictDto):
intent: ShortText
operation: Identifier
expected_change: ShortText
class TopologyRequest(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class GeometryConclusion(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class RollbackCheckpoint(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessTopologyRequest(StrictDto):
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
limit: int = Field(default=16, ge=1, le=64)
class StatelessGeometryConclusion(StrictDto):
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
decision: Literal["return_to_action_selection", "rollback"]
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
class StatelessRollbackCheckpoint(StrictDto):
checkpoint_token: Identifier
reason: Annotated[str, Field(min_length=1, max_length=360)]
class ClaimCoverage(StrictDto):
claim_id: Identifier
status: Literal["pass", "pending", "fail", "not_applicable"]
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
@model_validator(mode="after")
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
if len(self.evidence_refs) != len(set(self.evidence_refs)):
raise ValueError("evidence_refs must not contain duplicates")
return self
class CandidateReview(StrictDto):
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class StatelessCandidateReview(StrictDto):
verdict: Literal["accept", "reject"]
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class VisualClaimDecision(StrictDto):
status: Literal["pass", "fail"]
evidence: Annotated[str, Field(min_length=1, max_length=360)]
class StatelessFinalReview(StrictDto):
verdict: Literal["pass", "repair"]
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
class ImageMeasurement(StrictDto):
name: Annotated[str, Field(min_length=1, max_length=160)]
value: float | None = None
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
evidence: Annotated[str, Field(min_length=1, max_length=360)]
confidence: float = Field(ge=0, le=1)
class ImageObservation(StrictDto):
summary: Annotated[str, Field(min_length=1, max_length=2000)]
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
class FinalReview(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
schema = RequirementsAuthorOutput.model_json_schema()
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
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", {})
if isinstance(properties, dict):
properties["operation"] = {"enum": atomic_ids}
return schema
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
schema = StatelessFinalReview.model_json_schema()
properties = schema.get("properties", {})
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
if isinstance(visual, dict):
visual["minItems"] = visual_claim_count
visual["maxItems"] = visual_claim_count
return schema
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
schema = StatelessRollbackCheckpoint.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def topology_request_schema(working_head: str) -> dict[str, Any]:
schema = TopologyRequest.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
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()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
return schema
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
definitions = schema.get("$defs", {})
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
if not isinstance(coverage, dict):
return
properties = coverage.get("properties", {})
if isinstance(properties, dict):
properties["claim_id"] = {"enum": claim_ids}
Dto = TypeVar("Dto", bound=StrictDto)
def raw_arguments_hash(raw_arguments_json: str) -> str:
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
def json_depth(value: Any, current: int = 0) -> int:
if isinstance(value, dict):
return max([current, *(json_depth(item, current + 1) for item in value.values())])
if isinstance(value, list):
return max([current, *(json_depth(item, current + 1) for item in value)])
return current
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
"""Bound and parse raw arguments before any schema-specific validation."""
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
try:
value = json.loads(raw_arguments_json)
except json.JSONDecodeError as error:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
if not isinstance(value, dict):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
if json_depth(value) > max_depth:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
if _contains_non_finite_number(value):
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
return value
def _contains_non_finite_number(value: Any) -> bool:
if isinstance(value, float):
return not math.isfinite(value)
if isinstance(value, dict):
return any(_contains_non_finite_number(item) for item in value.values())
if isinstance(value, list):
return any(_contains_non_finite_number(item) for item in value)
return False
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
"""Revalidate raw arguments against the current dynamic JSON Schema."""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
errors = [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
]
if errors:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
return None
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
"""Parse raw tool arguments once and return field-level DTO failures safely."""
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
if isinstance(value, WorkflowError):
return value
try:
return model.model_validate(value)
except ValidationError as error:
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
"""Require exactly one known tool call; no provider parser is trusted."""
if len(tool_calls) != 1:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
name = str(function.get("name") or "") if isinstance(function, dict) else ""
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
if name != allowed_name:
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
return name, raw
@@ -1,670 +0,0 @@
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
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"})
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
_BORE_MARKERS = ("bore", "hole", "孔")
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
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, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
self.repository = repository
self.artifacts = artifacts
self.registry = registry
self.atomic_ids = atomic_ids or (lambda: ())
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:
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)
]
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
claims = self._evaluation_contract_oracles.get(task_id)
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, [])),
}
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
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 feature_plan_schema(self, task_id: str) -> dict[str, Any]:
"""Return the plan tool schema bound to the persisted planning state.
A model is allowed to choose node content, but it must not guess the
immutable lineage identifiers of a plan revision. Binding those values
as enums prevents a requirements-contract hash (or a stale plan hash)
from being mistaken for ``parent_plan_hash``.
"""
schema = FeaturePlan.model_json_schema()
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
node_properties = node.get("properties") if isinstance(node, dict) else None
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
if isinstance(atomic, dict):
atomic["enum"] = list(self.atomic_ids())
state = self.repository.get_state(task_id)
previous: FeaturePlan | None = None
if state is not None and state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
previous = None
parent_hash = plan_hash(previous) if previous is not None else ""
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
if isinstance(parent, dict):
parent["enum"] = [parent_hash]
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
if isinstance(replaced, dict):
replaced.update({
"type": "array",
"uniqueItems": True,
"minItems": len(replacements),
"maxItems": len(replacements),
"items": {"enum": replacements},
})
contract = self.artifacts.read_requirements_contract(
task_id,
state.requirements_contract_path if state is not None else "",
) or {}
deterministic_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") == "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
visual_claim_ids = sorted(
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
and claim.get("claim_id")
)
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
if isinstance(node_claim_ids, dict):
# Authors occasionally repeat a visual claim on the node that
# creates the feature as well as in final_claim_ids. Accept that
# harmless reference at the tool boundary; submit_feature_plan()
# removes it before the immutable DAG is validated and written.
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
if isinstance(final_claim_ids, dict):
final_claim_ids["items"] = {"enum": visual_claim_ids}
final_claim_ids["maxItems"] = len(visual_claim_ids)
return schema
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:
# Compatibility shim for callers compiled against v3.1. The v3.2
# coordinator never offers this method to an LLM.
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
"""Freeze a validated initial plan or full subgraph plan revision."""
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 not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
plan = self._normalize_feature_plan_visual_references(plan, contract)
plan = self._assign_unowned_global_health_claims(plan, contract)
previous: FeaturePlan | None = None
completed: dict[str, str] = {}
if state.feature_plan_path:
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
try:
previous = FeaturePlan.model_validate(raw)
except ValueError:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
digest = plan_hash(plan)
path = f"plans/feature-plan-{digest}.json"
event = "feature_plan_written" if previous is None else "feature_plan_revised"
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
events: list[dict[str, Any]] = [{
"event": event,
"invocation_id": invocation_id,
"plan_path": written,
"plan_hash": digest,
"parent_plan_hash": plan.parent_plan_hash,
"replaces_node_ids": plan.replaces_node_ids,
}]
if previous is not None:
old_nodes = {node.node_id: node for node in previous.nodes}
old_hash = plan_hash(previous)
events.extend({
"event": "feature_node_invalidated",
"node_id": node_id,
"node_hash": node_hash(old_nodes[node_id]),
"plan_hash": old_hash,
"replacement_plan_hash": digest,
} for node_id in plan.replaces_node_ids)
if not self._commit(next_state, events, invocation, result):
return Rejected(self._stale())
return result
@staticmethod
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Drop non-owning visual references from feature nodes.
A node's ``claim_ids`` drive synchronous deterministic acceptance.
Visual claims are owned solely by ``final_claim_ids`` and have no
node-local verifier. Retaining a repeated visual ID therefore adds
no behavior and turns an otherwise valid plan into a schema retry.
The contract validation below still requires every visual claim to be
present exactly once in ``final_claim_ids``.
"""
visual_claim_ids = {
str(claim.get("claim_id") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
and claim.get("verification_mode") != "deterministic"
and isinstance(claim.get("claim_id"), str)
}
if not visual_claim_ids or not any(
claim_id in visual_claim_ids
for node in plan.nodes
for claim_id in node.claim_ids
):
return plan
normalized = plan.model_copy(deep=True)
for node in normalized.nodes:
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
return normalized
@staticmethod
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
"""Bind global solid-health claims to the unique root body feature.
``single_connected_body`` and ``solid_count_equals`` are checked as
global health on every feature checkpoint. When a plan has exactly
one root additive feature, their node owner is consequently
determined without choosing any geometry strategy. This prevents an
otherwise complete plan from failing merely because an author omitted
the redundant ownership annotation.
"""
claims = {
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
for requirement in contract.get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
}
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
unowned = [
claim_id for claim_id, claim_kind in claims.items()
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
]
roots = [
node for node in plan.nodes
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
]
if not unowned or len(roots) != 1:
return plan
normalized = plan.model_copy(deep=True)
root_id = roots[0].node_id
for node in normalized.nodes:
if node.node_id == root_id:
node.claim_ids = [*node.claim_ids, *unowned]
break
return normalized
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
statuses = scheduler.statuses()
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
if not failed:
return set()
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
for node in plan.nodes:
for dependency in node.depends_on:
children.setdefault(dependency, set()).add(node.node_id)
result = set(failed)
pending = list(failed)
while pending:
current = pending.pop()
for child in children.get(current, set()):
if statuses.get(child) != "done" and child not in result:
result.add(child)
pending.append(child)
return result
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.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),
*self._relationship_claim_errors(normalized_output, targets),
]
if field_errors:
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))
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, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
claims: list[dict[str, Any]] = []
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": 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.2", "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)
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_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())
return result
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", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses: list[str] = []
evidence: list[str] = []
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict):
continue
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 _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:
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))
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 _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
@staticmethod
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
"""Require explicit coverage for an unambiguous centered-bore target.
The compiler remains free to choose claims for open-ended CAD prose.
A checklist item that explicitly says a bore is centered/concentric is
different: dropping that relationship leaves a measurable user fact
with no acceptance owner. The service only checks its presence here;
the registry validates its numeric parameters independently.
"""
errors: list[dict[str, str]] = []
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(
claim.claim_kind == "concentric_bore_to_outer_cylinder"
for claim in requirement.acceptance_claims
):
continue
errors.append({
"path": f"/requirements/{index}/acceptance_claims",
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
})
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 self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
warnings.append(
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_unbacked_coaxial_bore_group(normalized, claim):
warnings.append(
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
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]},
})]
self._derive_centered_bore_claims(normalized, targets)
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
"""Keep a circular-hole verifier from accepting or rejecting a slot.
``rectangular_corner_through_bore_pattern`` measures four complete
cylindrical bores at equal edge offsets. An obround slot has two arc
ends and straight flanks; treating its stated length as an edge offset
produces an unsatisfiable contract even when the CAD is correct.
"""
return (
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
)
@staticmethod
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
"""Attach a measurable concentricity claim when its inputs are frozen.
The requirements compiler receives a Markdown checklist, not runtime
geometry IDs. Once it has already compiled an external cylindrical
diameter and an explicitly centred bore diameter, their relationship
is a service-owned mechanical consequence. Requiring an author to
remember the internal verifier name makes a complete user request
fail for a bookkeeping omission rather than a CAD decision.
"""
outer_diameters: list[float] = []
for requirement in output.requirements:
for claim in requirement.acceptance_claims:
expected = claim.expected
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
outer_diameters.append(float(diameter))
if not outer_diameters:
return
outer_diameter = max(outer_diameters)
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
for target, requirement in zip(targets, output.requirements, strict=True):
lowered = target.casefold()
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
continue
if not any(marker in lowered for marker in _BORE_MARKERS):
continue
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
continue
bore_diameter = next((
float(claim.expected["diameter_mm"])
for claim in requirement.acceptance_claims
if claim.claim_kind in bore_claim_kinds
and isinstance(claim.expected, dict)
and isinstance(claim.expected.get("diameter_mm"), (int, float))
and float(claim.expected["diameter_mm"]) > 0
), None)
if bore_diameter is None:
continue
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
"claim_kind": "concentric_bore_to_outer_cylinder",
"expected": {
"bore_diameter_mm": bore_diameter,
"outer_diameter_mm": outer_diameter,
"tolerance_mm": 0.01,
},
}))
@staticmethod
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
"""Reject a bore-group verifier when the contract has no such group.
``coaxial_through_bore_group`` measures multiple inner bores of one
diameter. It cannot prove a lone central bore is concentric with an
external cylindrical wall. This is a mechanical consistency check:
some through-bore claim must request at least the group count.
"""
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
return False
expected = getattr(claim, "expected", {})
if not isinstance(expected, dict):
return True
diameter = expected.get("diameter_mm")
count = expected.get("count")
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
return True
for requirement in output.requirements:
for candidate in requirement.acceptance_claims:
candidate_expected = getattr(candidate, "expected", {})
if (
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
and isinstance(candidate_expected, dict)
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
and isinstance(candidate_expected.get("count"), int)
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
and int(candidate_expected["count"]) >= count
):
return False
return True
@staticmethod
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) -> 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()).hexdigest()
@staticmethod
def _stale() -> WorkflowError:
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
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]}])
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
@@ -1,29 +0,0 @@
"""Typed results returned by all command handlers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.cad_agent.domain.errors import WorkflowError
@dataclass(frozen=True, slots=True)
class Accepted:
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class Rejected:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class Waiting:
error: WorkflowError
@dataclass(frozen=True, slots=True)
class FailedInternal:
correlation_id: str
message: str
@@ -0,0 +1,99 @@
"""Deterministic execution service for the single-stage Authoring protocol.
The service is deliberately synchronous and side-effect bounded so the HTTP
workflow can call it after one author response (and at most two repairs).
"""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from app.cad_agent.ports import AdapterUnavailable
class SingleStageExecutor:
MAX_REPAIRS = 2
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compile once and persist immutable compiler inputs and output."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
digest = self._digest(authoring)
attempt = repair_count + 1
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
self.artifacts.write_json_once(task_id, audit_path, audit)
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
"""Build one compiled CDSL document and publish any executable prefix."""
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
raise ValueError("repair budget exhausted")
digest = digest or self._digest(authoring)
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
"schema_version": "cad.single-stage.v1", "authoring": authoring,
"runtime": runtime_cdsl, "compile_audit": audit,
})
try:
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
except (OSError, AdapterUnavailable):
raise
except Exception as error:
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
})
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
diagnostics = self._decorate_diagnostics(failures, audit)
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
"schema_version": "cad.build-diagnostics.v1",
"diagnostics": diagnostics,
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
if rebuilt:
revision = f"rev_{digest}"
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
"schema_version": "cad.single-stage.staging-manifest.v1",
"stage_id": stage.stage_id,
"revision_id": revision,
"repair_count": repair_count,
"source_sha256": audit.get("source_sha256", ""),
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
})
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
else:
paths = {}
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
"""Compatibility convenience for direct callers and focused tests."""
compiled = self.compile(task_id, authoring, repair_count=repair_count)
return {
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
}
@staticmethod
def _digest(authoring: dict[str, Any]) -> str:
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
return sha256(encoded).hexdigest()[:16]
@staticmethod
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
names = {
str(feature_id): str(name)
for name, feature_id in (audit.get("feature_ids") or {}).items()
if isinstance(name, str) and isinstance(feature_id, str)
}
result: list[dict[str, Any]] = []
for failure in failures:
if not isinstance(failure, dict):
continue
feature_id = str(failure.get("feature_id") or "")
result.append({**failure, "feature_name": names.get(feature_id, "")})
return result
File diff suppressed because it is too large Load Diff
+28 -40
View File
@@ -1,4 +1,4 @@
"""Protocol v3 composition root. This is the only layer joining adapters.""" """Single-stage Authoring CDSL composition root."""
from __future__ import annotations from __future__ import annotations
@@ -6,70 +6,58 @@ from dataclasses import dataclass
import shutil import shutil
from app.cad_agent.adapters.artifact_store import FileArtifactStore from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.author_guidance import FileAuthorGuidance
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
from app.cad_agent.adapters.runtime import ProfileCadRuntime from app.cad_agent.adapters.runtime import ProfileCadRuntime
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
from app.cad_agent.adapters.structured_llm import StructuredModelGateway from app.cad_agent.adapters.structured_llm import StructuredModelGateway
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.outbox import OutboxDispatcher from app.cad_agent.application.outbox import OutboxDispatcher
from app.cad_agent.application.requirements import RequirementsCommandHandler from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator from app.cad_agent.application.single_stage import SingleStageExecutor
from app.cad_agent.domain.verifier_registry import default_registry from app.settings import Settings
from app.settings import BACKEND_ROOT, Settings
from app.services.storage import WorkspaceStore from app.services.storage import WorkspaceStore
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class V3Services: class CadServices:
repository: SqliteTaskRepository repository: SqliteTaskRepository
artifacts: FileArtifactStore artifacts: FileArtifactStore
workflow: WorkflowCoordinator workflow: WorkflowCoordinator
models: StructuredModelGateway models: StructuredModelGateway
outbox: OutboxDispatcher outbox: OutboxDispatcher
single_stage: SingleStageExecutor
def compose_v3(settings: Settings) -> V3Services: def compose_cad_services(settings: Settings) -> CadServices:
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3") database_root = settings.task_root.parent
if repository.protocol_reset and settings.task_root.exists(): legacy_database = database_root / "autonomous-cad-v3.sqlite3"
# Protocol 3.1 has no valid interpretation for structured-only task removed_legacy_database = False
# artifacts, so clear that task root together with its old database. if legacy_database.exists():
# The removed coordinator persisted incompatible task/action state in
# its own database. Delete it as part of the deliberate destructive
# migration, including SQLite sidecars if a worker stopped mid-write.
for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))):
if candidate.exists():
candidate.unlink()
removed_legacy_database = True
repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3")
protocol_reset = repository.protocol_reset or removed_legacy_database
if protocol_reset and settings.task_root.exists():
# Old task artifacts have no valid interpretation under the Authoring
# protocol, so clear them together with the task database.
shutil.rmtree(settings.task_root) shutil.rmtree(settings.task_root)
if repository.protocol_reset: if protocol_reset:
WorkspaceStore(settings).clear_current_task_references() WorkspaceStore(settings).clear_current_task_references()
artifacts = FileArtifactStore(settings.task_root) artifacts = FileArtifactStore(settings.task_root)
runtime = ProfileCadRuntime(settings) runtime = ProfileCadRuntime(settings)
registry = default_registry()
verifier = RegistryVerifierExecutor(registry)
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
fallbacks = tuple(
ModelIdentity(provider.id, model.id)
for provider in settings.providers
if provider.configured
for model in provider.models[:1]
)
models = StructuredModelGateway(settings) models = StructuredModelGateway(settings)
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher()) outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
single_stage = SingleStageExecutor(repository, artifacts, runtime)
workflow = WorkflowCoordinator( workflow = WorkflowCoordinator(
WorkflowConfig( WorkflowConfig(),
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
format_error_limit=settings.agent_format_error_repeat_limit,
author_fallbacks=fallbacks,
),
repository, repository,
artifacts, artifacts,
runtime, runtime,
models, models,
RenderedReviewGateway(models), single_stage,
requirements,
actions,
FileAuthorGuidance(
BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance",
enabled=settings.agent_author_guidance_enabled,
max_chars=settings.agent_author_guidance_max_chars,
),
) )
return V3Services(repository, artifacts, workflow, models, outbox) return CadServices(repository, artifacts, workflow, models, outbox, single_stage)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Pure domain objects and policies for protocol v3.""" """Pure domain objects and policies for the Authoring CDSL protocol."""
from .errors import ErrorCode, WorkflowError from .errors import ErrorCode, WorkflowError
from .state import TaskPhase, TaskState, transition from .state import TaskPhase, TaskState, transition
@@ -1,20 +0,0 @@
"""Canonical partial matching for acceptance-claim expectations."""
from __future__ import annotations
from typing import Any
def contains_expected(actual: Any, required: Any) -> bool:
"""Allow an actual claim to add provider-chosen optional fields only."""
if isinstance(required, dict):
return isinstance(actual, dict) and all(
key in actual and contains_expected(actual[key], value)
for key, value in required.items()
)
if isinstance(required, list):
return isinstance(actual, list) and len(actual) == len(required) and all(
contains_expected(actual_item, required_item)
for actual_item, required_item in zip(actual, required, strict=True)
)
return actual == required
+11 -15
View File
@@ -1,4 +1,4 @@
"""Typed, serializable failures used across the v3 workflow.""" """Typed, serializable failures used across the single-stage workflow."""
from __future__ import annotations from __future__ import annotations
@@ -9,29 +9,25 @@ from typing import Any
class ErrorCode(StrEnum): class ErrorCode(StrEnum):
CANCELLED = "CANCELLED" CANCELLED = "CANCELLED"
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID" RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED" MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE" AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE" RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
STORAGE_FAILURE = "STORAGE_FAILURE" STORAGE_FAILURE = "STORAGE_FAILURE"
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE" RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED" BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
FAILED_INTERNAL = "FAILED_INTERNAL" FAILED_INTERNAL = "FAILED_INTERNAL"
WAITING_FOR_USER = "WAITING_FOR_USER" WAITING_FOR_USER = "WAITING_FOR_USER"
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
AUTHOR_CYCLE = "AUTHOR_CYCLE"
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -1,265 +0,0 @@
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
from __future__ import annotations
from hashlib import sha256
import json
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
class FeatureNode(_StrictModel):
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
priority: int = Field(ge=0, le=100_000)
intent: str = Field(min_length=1, max_length=360)
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
depends_on: list[str] = Field(default_factory=list, max_length=64)
claim_ids: list[str] = Field(default_factory=list, max_length=128)
expected_change: str = Field(min_length=1, max_length=360)
@model_validator(mode="after")
def _unique_references(self) -> "FeatureNode":
if len(self.depends_on) != len(set(self.depends_on)):
raise ValueError("depends_on must not contain duplicates")
if len(self.claim_ids) != len(set(self.claim_ids)):
raise ValueError("claim_ids must not contain duplicates")
if self.node_id in self.depends_on:
raise ValueError("a feature node cannot depend on itself")
return self
class FeaturePlan(_StrictModel):
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
@model_validator(mode="after")
def _unique_plan_fields(self) -> "FeaturePlan":
node_ids = [node.node_id for node in self.nodes]
priorities = [node.priority for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("node_id values must be unique")
if len(priorities) != len(set(priorities)):
raise ValueError("priority values must be unique")
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
raise ValueError("replaces_node_ids must not contain duplicates")
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
raise ValueError("final_claim_ids must not contain duplicates")
return self
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
for claim in requirement.get("acceptance_claims") or ():
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
result[claim["claim_id"]] = claim
return result
def validate_feature_plan(
plan: FeaturePlan,
contract: dict[str, Any],
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
*,
previous_plan: FeaturePlan | None = None,
completed_node_hashes: dict[str, str] | None = None,
required_replacements: set[str] | None = None,
) -> list[dict[str, str]]:
"""Return stable, tool-facing validation errors for a frozen plan."""
errors: list[dict[str, str]] = []
nodes = {node.node_id: node for node in plan.nodes}
known_atoms = set(atomic_ids)
claims = contract_claims(contract)
assigned: dict[str, str] = {}
for index, node in enumerate(plan.nodes):
prefix = f"/nodes/{index}"
if node.atomic_id not in known_atoms:
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
for dependency in node.depends_on:
if dependency not in nodes:
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
for claim_id in node.claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
continue
if claim.get("verification_mode") != "deterministic":
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
owner = assigned.setdefault(claim_id, node.node_id)
if owner != node.node_id:
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
final = set(plan.final_claim_ids)
for claim_id in plan.final_claim_ids:
claim = claims.get(claim_id)
if claim is None:
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
elif claim.get("verification_mode") == "deterministic":
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
for claim_id, claim in claims.items():
deterministic = claim.get("verification_mode") == "deterministic"
if deterministic and claim_id not in assigned:
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
if not deterministic and claim_id not in final:
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
if deterministic and claim_id in final:
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
errors.extend(_cycle_errors(nodes))
if previous_plan is None:
if plan.parent_plan_hash:
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
if plan.replaces_node_ids:
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
else:
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
return errors
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
visiting: set[str] = set()
visited: set[str] = set()
errors: list[dict[str, str]] = []
def walk(current: str) -> None:
if current in visiting:
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
return
if current in visited:
return
visiting.add(current)
for dependency in nodes[current].depends_on:
if dependency in nodes:
walk(dependency)
visiting.remove(current)
visited.add(current)
for node_id in nodes:
walk(node_id)
return errors[:1]
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
old_nodes = {node.node_id: node for node in previous.nodes}
next_nodes = {node.node_id: node for node in plan.nodes}
if plan.parent_plan_hash != plan_hash(previous):
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
replaced = set(plan.replaces_node_ids)
if required_replacements and replaced != required_replacements:
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
for node_id, frozen_hash in completed.items():
node = next_nodes.get(node_id)
if node is None:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
elif node_hash(node) != frozen_hash:
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
if node_id in replaced:
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
for node_id, old_node in old_nodes.items():
if node_id in replaced:
continue
current = next_nodes.get(node_id)
if current is None:
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
elif node_hash(current) != node_hash(old_node):
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
for node_id in replaced:
if node_id not in old_nodes:
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
if node_id in next_nodes:
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
return errors
class FeatureScheduler:
"""Derive a plan's runnable node from immutable ledger evidence."""
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
self.plan = plan
self.events = events
self._nodes = {node.node_id: node for node in plan.nodes}
def statuses(self) -> dict[str, str]:
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
for event in self.events:
node_id = str(event.get("node_id") or "")
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
continue
if event.get("event") == "feature_node_verified":
states[node_id] = "done"
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
states[node_id] = "invalidated"
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
states[node_id] = "running"
for node in self.plan.nodes:
if states[node.node_id] in {"done", "failed", "invalidated"}:
continue
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
states[node.node_id] = "blocked"
elif all(value == "done" for value in dependency_states):
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
return states
def next_ready(self) -> FeatureNode | None:
statuses = self.statuses()
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
return min(ready, key=lambda node: node.priority) if ready else None
def all_done(self) -> bool:
return all(value == "done" for value in self.statuses().values())
def feature_ids(self) -> dict[str, str]:
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
result: dict[str, str] = {}
for event in self.events:
node_id = str(event.get("node_id") or "")
feature_id = str(event.get("feature_id") or "")
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
result[node_id] = feature_id
return result
def completed_node_hashes(self) -> dict[str, str]:
statuses = self.statuses()
return {
node_id: node_hash(self._nodes[node_id])
for node_id, status in statuses.items()
if status == "done"
}
def failure_count(self, node_id: str, failure_class: str) -> int:
expected = node_hash(self._nodes[node_id])
return sum(
1
for event in self.events
if event.get("event") == "feature_node_failed"
and event.get("node_id") == node_id
and event.get("node_hash") == expected
and event.get("failure_class") == failure_class
)
@@ -1,8 +1,7 @@
"""Versioned runtime operation contracts and dynamic fragment schemas.""" """Versioned runtime operation contracts for the Authoring compiler."""
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from hashlib import sha256 from hashlib import sha256
import json import json
from typing import Any from typing import Any
@@ -15,25 +14,6 @@ class OperationContractError(ValueError):
pass pass
SEMANTIC_PREFLIGHT_NAMES = frozenset({
"sketch_workplane",
"profile_non_self_intersecting",
"host_face_exists",
"hole_positions_on_host_plane",
"cut_exit_distance",
"requires_active_solid",
"revolve_axis_on_sketch",
"reference_plane_nonzero_normal",
"reference_axis_nonzero_direction",
"selected_edges_exist",
"source_features_exist",
"mirror_plane_exists",
"loft_profiles_exist",
"loft_profiles_closed",
"loft_profiles_single_region",
})
def canonical_hash(value: dict[str, Any]) -> str: def canonical_hash(value: dict[str, Any]) -> str:
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -58,10 +38,16 @@ def _is_closed(schema: Any) -> bool:
return True return True
def is_authoring_schema_closed(schema: Any) -> bool:
"""Whether an operation can safely be exposed in the Authoring whitelist."""
return _is_closed(schema)
def validate_operation_contract(contract: dict[str, Any]) -> None: def validate_operation_contract(contract: dict[str, Any]) -> None:
required = { required = {
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy", "atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers", "server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
"runtime_capability",
} }
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}: if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
raise OperationContractError("Operation contract has unknown or missing fields") raise OperationContractError("Operation contract has unknown or missing fields")
@@ -111,113 +97,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
if reference["mode"] == "snapshot_bound": if reference["mode"] == "snapshot_bound":
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}: if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
raise OperationContractError("Snapshot-bound reference policy is invalid") raise OperationContractError("Snapshot-bound reference policy is invalid")
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True: slot = reference["slot"]
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
reference_name = reference["slot"].removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
required_params = params.get("required") if isinstance(params.get("required"), list) else []
if ( if (
not isinstance(reference_schema, dict) not isinstance(slot, str)
or reference_schema.get("type") != "array" or (slot != "feature.selectors" and not slot.startswith("params."))
or not isinstance(reference_schema.get("items"), dict) or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"}
or reference_name not in required_params or not isinstance(reference["min_items"], int)
or not isinstance(reference["max_items"], int)
or not 0 <= reference["min_items"] <= reference["max_items"] <= 64
or reference["snapshot_bound"] is not True
): ):
raise OperationContractError("Snapshot-bound reference slot must be a required author array") raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
if slot == "feature.selectors":
if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]:
raise OperationContractError("Feature selector reference policy disagrees with selector policy")
else:
reference_name = slot.removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}:
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
if reference_schema.get("type") == "string" and reference["max_items"] > 1:
raise OperationContractError("Scalar snapshot-bound reference must allow at most one item")
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]): if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight is invalid") raise OperationContractError("Operation semantic preflight is invalid")
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES: if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight names are unknown or duplicated") raise OperationContractError("Operation semantic preflight names are duplicated")
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]): if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate_verifiers are invalid") raise OperationContractError("Operation candidate_verifiers are invalid")
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]): if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate verifiers are duplicated") raise OperationContractError("Operation candidate verifiers are duplicated")
runtime_capability = contract["runtime_capability"]
required_capability_flags = {
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]: "body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
"""Build the one-operation schema exposed for one pending action."""
validate_operation_contract(contract)
shape = contract["fragment_shape"]
params = deepcopy(contract["author_params_schema"])
reference = contract["reference_policy"]
if reference["mode"] == "snapshot_bound":
slot = str(reference["slot"]).removeprefix("params.")
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
if not isinstance(items, dict):
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
items.clear()
items.update({"enum": reference_tokens or []})
feature_properties: dict[str, Any] = {
"atomic_id": {"const": contract["atomic_id"]},
"params": params,
} }
feature_required = ["atomic_id", "params"] if (
if shape["selector_tokens"] == "required": not isinstance(runtime_capability, dict)
policy = contract["selector_policy"] or set(runtime_capability) != required_capability_flags
feature_properties["selector_tokens"] = { or not all(isinstance(value, bool) for value in runtime_capability.values())
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"], ):
"maxItems": policy["max_items"], "uniqueItems": True, raise OperationContractError("Operation runtime capability is invalid")
"description": (
"Required author input. Copy the opaque selector token returned by the current topology "
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
"token into the host selector after schema validation."
),
}
feature_required.append("selector_tokens")
feature = {
"type": "object",
"description": (
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
"rather than a server-filled params field."
),
"properties": feature_properties,
"required": feature_required,
"additionalProperties": False,
}
properties: dict[str, Any] = {"feature": feature}
required = ["feature"]
if shape["sketch"] == "required":
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, 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))
]
def _point(size: int) -> dict[str, Any]:
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
point2 = _point(2)
point3 = _point(3)
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},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
]
}
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
+70 -185
View File
@@ -1,56 +1,24 @@
"""Finite workflow state machine. This module has no persistence imports.""" """Finite state for the single-stage Authoring CDSL protocol."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from enum import StrEnum from enum import StrEnum
from .errors import ErrorCode, WorkflowError from .errors import ErrorCode
class TaskPhase(StrEnum): class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT" ANALYZING_REQUEST = "ANALYZING_REQUEST"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET" AUTHORING_CDSL = "AUTHORING_CDSL"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS" COMPILING_CDSL = "COMPILING_CDSL"
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN" BUILDING = "BUILDING"
SCHEDULING_FEATURE = "SCHEDULING_FEATURE" REPAIRING = "REPAIRING"
FEATURE_PENDING = "FEATURE_PENDING" PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
FEATURE_BUILDING = "FEATURE_BUILDING"
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
# Legacy v3.1 phases remain readable only so an interrupted process can
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
FINAL_VALIDATION = "FINAL_VALIDATION"
WAITING_RETRY = "WAITING_RETRY"
WAITING_FOR_USER = "WAITING_FOR_USER" WAITING_FOR_USER = "WAITING_FOR_USER"
CANCELLED = "CANCELLED"
COMPLETED = "COMPLETED" COMPLETED = "COMPLETED"
FAILED = "FAILED" FAILED = "FAILED"
CANCELLED = "CANCELLED"
@dataclass(frozen=True, slots=True)
class PendingAction:
action_id: str
working_head: str
intent: str
requirement_ids: tuple[str, ...]
atomic_id: str
expected_change: str
contract_hash: str
idempotency_key: str
node_id: str = ""
plan_hash: str = ""
claim_ids: tuple[str, ...] = ()
depends_on_node_ids: tuple[str, ...] = ()
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
# v3.2 code uses this alias to make the ownership boundary explicit.
PendingFeature = PendingAction
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -59,167 +27,84 @@ class TaskState:
phase: TaskPhase phase: TaskPhase
version: int version: int
active_revision: str = "" active_revision: str = ""
pending_action: PendingAction | None = None repair_count: int = 0
candidate_id: str = ""
candidate_stage_id: str = ""
repair_required: bool = False
last_error: ErrorCode | None = None last_error: ErrorCode | None = None
retry_from_phase: TaskPhase | None = None retry_from_phase: TaskPhase | None = None
requirements_spec_path: str = "" requirements_path: str = ""
requirements_document_path: str = "" authoring_path: str = ""
completion_target_path: str = "" runtime_cdsl_path: str = ""
modeling_plan_path: str = "" compile_audit_path: str = ""
feature_plan_path: str = "" diagnostics_path: str = ""
feature_plan_hash: str = "" completion_path: str = ""
feature_stage_id: str = ""
clarification_path: str = "" clarification_path: str = ""
requirements_contract_path: str = ""
@property
def pending_feature(self) -> PendingFeature | None:
return self.pending_action
@property
def working_head(self) -> str:
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
# Legal state transitions. Events are intentionally terse persistence-neutral
# names used by command handlers and architecture tests.
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = { _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, (TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET, (TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS, (TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN, (TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE, (TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
# Retained for direct v3.1 handler callers only. The v3.2 workflow never (TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
# exposes this event or accepts a Markdown plan from a model. (TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION, (TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE, (TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING, (TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION, (TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH, (TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(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_DOCUMENT,
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
# Runtime semantic preflight happens before candidate staging. It must
# leave the checkpoint untouched and return control to the author for a
# fresh observation/action decision, never masquerade as a format error.
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
} }
_TRANSITIONS.update({ for _phase in TaskPhase:
(phase, "best_effort_completed"): TaskPhase.COMPLETED if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
for phase in TaskPhase _TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} _TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
}) if _phase in {
_TRANSITIONS.update({ TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
(phase, "failed"): TaskPhase.FAILED TaskPhase.BUILDING, TaskPhase.REPAIRING,
for phase in TaskPhase }:
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} _TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
})
_TRANSITIONS.update({
(phase, "cancelled"): TaskPhase.CANCELLED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.COMPILING_FEATURE_PLAN,
TaskPhase.SCHEDULING_FEATURE,
TaskPhase.FEATURE_PENDING,
TaskPhase.FEATURE_BUILDING,
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
TaskPhase.CANDIDATE_BUILDING,
TaskPhase.CANDIDATE_REVIEW,
TaskPhase.FINAL_VALIDATION,
})
_TRANSITIONS.update({
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
for phase in _RETRY_RESUMABLE_PHASES
})
_TRANSITIONS.update({
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
})
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]: def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
"""Return a copy of the protocol transition table for architecture tests."""
return dict(_TRANSITIONS) return dict(_TRANSITIONS)
def retry_resume_event(state: TaskState) -> str | None: def transition(
"""Return the only lossless resume event for a parked service failure.""" state: TaskState,
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES: event: str,
return None *,
return f"resume_{state.retry_from_phase.value.lower()}" active_revision: str | None = None,
repair_count: int | None = None,
error: ErrorCode | None = None,
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, feature_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, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState: requirements_path: str | None = None,
"""Apply one legal transition and advance optimistic-concurrency version.""" authoring_path: str | None = None,
target = _TRANSITIONS.get((state.phase, event)) runtime_cdsl_path: str | None = None,
compile_audit_path: str | None = None,
diagnostics_path: str | None = None,
completion_path: str | None = None,
clarification_path: str | None = None,
) -> TaskState:
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
if target is None: if target is None:
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?") raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target: next_repairs = state.repair_count if repair_count is None else repair_count
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase") if not 0 <= next_repairs <= 2:
next_pending = state.pending_action if pending_action is ... else pending_action raise ValueError("Single-stage repair count must be within [0, 2]")
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
next_pending = None
return replace( return replace(
state, state,
phase=target, phase=target,
version=state.version + 1, version=state.version + 1,
active_revision=state.active_revision if active_revision is None else active_revision, active_revision=state.active_revision if active_revision is None else active_revision,
pending_action=next_pending, repair_count=next_repairs,
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
repair_required=state.repair_required if repair_required is None else repair_required,
last_error=error, last_error=error,
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None, retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path, ErrorCode.RUNTIME_EXECUTION_FAILURE,
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path, } else None,
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path, requirements_path=state.requirements_path if requirements_path is None else requirements_path,
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path, authoring_path=state.authoring_path if authoring_path is None else authoring_path,
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash, runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id, compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
completion_path=state.completion_path if completion_path is None else completion_path,
clarification_path=state.clarification_path if clarification_path is None else clarification_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,
) )
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
if supplied_head != state.working_head:
return WorkflowError(
ErrorCode.STALE_WORKING_HEAD,
"The command was bound to an obsolete working head.",
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
)
return None
@@ -1,21 +0,0 @@
# Token Baseline Format
`python -m app.cad_agent.evals.live --suite release --require-live` requires
`--baseline-report` to point at a measured protocol-2 report. It is not a
fixture or an estimate. Record three independent runs of every release
scenario with the same author request configuration and runtime profile.
| Field | Requirement |
| --- | --- |
| `schema_version` | `cad.token-baseline.v1` |
| `protocol_version` | `2.0` |
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
The comparison uses `author_metric + plan_review_metric` for the v2 median and
the v3 author metric only. It fails closed when provenance differs, any
repetition is absent, completion/review/claim rates drop, plan-review calls
remain, or the median reduction is below 30%.
+1 -1
View File
@@ -1 +1 @@
"""Live, non-mocked protocol v3 release evaluations.""" """Local evaluation helpers for the single-stage Authoring protocol."""
@@ -1,4 +1,4 @@
"""Create a resumable live-evaluation task without starting its workflow.""" """Create an isolated single-stage task without starting its workflow."""
from __future__ import annotations from __future__ import annotations
@@ -9,12 +9,12 @@ from pathlib import Path
import secrets import secrets
import sys import sys
from app.cad_agent.composition import compose_v3 from app.cad_agent.composition import compose_cad_services
from app.settings import get_settings from app.settings import get_settings
def _arguments() -> argparse.Namespace: def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.") parser = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.")
parser.add_argument("--report-root", required=True, type=Path) parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--prompt", required=True) parser.add_argument("--prompt", required=True)
parser.add_argument("--task-id") parser.add_argument("--task-id")
@@ -25,7 +25,7 @@ def main() -> int:
arguments = _arguments() arguments = _arguments()
root = arguments.report_root.resolve() root = arguments.report_root.resolve()
settings = get_settings() settings = get_settings()
services = compose_v3(replace( services = compose_cad_services(replace(
settings, settings,
task_root=root / "artifacts", task_root=root / "artifacts",
conversation_root=root / "conversations", conversation_root=root / "conversations",
@@ -1,423 +0,0 @@
{
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
"scenarios": [
{
"id": "rectangular_mounting_plate",
"units": "mm",
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R6 outer corner radii"}
],
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
},
{
"id": "circular_flange_pcd",
"units": "mm",
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
],
"required_atomic_ids": [],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
"validation_capability_gaps": [],
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
},
{
"id": "square_flange",
"units": "mm",
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R8 outer corner radii"}
],
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
},
{
"id": "counterbored_mounting_plate",
"units": "mm",
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
"validation_capability_gaps": [],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "countersunk_cover_plate",
"units": "mm",
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
"validation_capability_gaps": [
{"id": "corner_radius", "description": "R5 outer corner radii"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "obround_slot_plate",
"units": "mm",
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "t_slot_test_block",
"units": "mm",
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "rounded_rectangular_pocket",
"units": "mm",
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
],
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
},
{
"id": "two_level_pocket_plate",
"units": "mm",
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
],
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
},
{
"id": "cross_drilled_valve_block",
"units": "mm",
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "double_hole_linkage_arm",
"units": "mm",
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "three_hole_linkage",
"units": "mm",
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "l_bracket",
"units": "mm",
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
],
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
},
{
"id": "ribbed_l_bracket",
"units": "mm",
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
],
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
},
{
"id": "u_bearing_support",
"units": "mm",
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
],
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
},
{
"id": "double_lug_mount",
"units": "mm",
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
],
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
},
{
"id": "stepped_shaft",
"units": "mm",
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["fillet", "chamfer"],
"validation_capability_gaps": [
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
},
{
"id": "keyed_stepped_shaft",
"units": "mm",
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
"validation_capability_gaps": [
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "flanged_sleeve",
"units": "mm",
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"validation_capability_gaps": [
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
],
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
},
{
"id": "chamfered_bushing",
"units": "mm",
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
"required_claims": [
{"claim_kind": "single_connected_body", "expected": {}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
{"claim_kind": "visual", "expected": {}}
],
"required_atomic_ids": ["revolve_add"],
"required_any_atomic_ids": ["chamfer"],
"validation_capability_gaps": [
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
],
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
}
]
}
@@ -1,78 +0,0 @@
{
"schema_version": "cad.live-eval-fixtures.v1",
"scenarios": [
{
"id": "rectangular_plate",
"units": "mm",
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
],
"required_atomic_ids": ["extrude_add_blind"],
"max_author_turns": 32,
"max_reviewer_turns": 12,
"max_total_calls": 44,
"max_wall_seconds": 900,
"max_total_tokens": 120000
},
{
"id": "simple_flange",
"units": "mm",
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
"max_author_turns": 40,
"max_reviewer_turns": 16,
"max_total_calls": 56,
"max_wall_seconds": 1200,
"max_total_tokens": 160000
},
{
"id": "ribbed_mounting_plate",
"units": "mm",
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
],
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
"max_author_turns": 56,
"max_reviewer_turns": 20,
"max_total_calls": 76,
"max_wall_seconds": 1500,
"max_total_tokens": 200000
},
{
"id": "selector_finish",
"units": "mm",
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
"expected_phase": "COMPLETED",
"required_claim_kinds": ["solid_count_equals"],
"required_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
],
"required_atomic_ids": ["extrude_add_blind"],
"required_any_atomic_ids": ["fillet", "chamfer"],
"max_author_turns": 40,
"max_reviewer_turns": 16,
"max_total_calls": 56,
"max_wall_seconds": 1200,
"max_total_tokens": 160000
}
]
}
-990
View File
@@ -1,990 +0,0 @@
"""Run the real provider, engine, reviewer and v3 state machine end to end.
``--require-live`` deliberately fails closed: missing credentials, blocked
network, a skipped model capability, a timeout, or any scenario failure emits
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
from hashlib import sha256
import json
from pathlib import Path
import secrets
from statistics import median
import subprocess
import sys
from typing import Any
from app.cad_agent.application.capabilities import verify_model_capability
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.cad_agent.domain.claim_matching import contains_expected
from app.cad_agent.domain.operation_contract import canonical_hash
from app.cad_agent.domain.verifier_registry import default_registry
from app.cad_agent.evals.token_baseline import (
TokenBaselineError,
author_request_identity,
compare_token_baseline,
load_token_baseline,
profile_sha256,
validate_token_baseline_provenance,
)
from app.settings import BACKEND_ROOT, get_settings
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
"candidate.json",
"candidate-review.json",
"model.cdsl.json",
"model.step",
"model.glb",
"model.topology.json",
"rebuild-report.json",
"renders/render-manifest.json",
"renders/contact-sheet.jpg",
})
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
# It deliberately has no candidate review or technical render bundle; the
# final review owns that later evidence. GLB is optional because a preview
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
"input.json",
"model.cdsl.json",
"model.step",
"model.topology.json",
"node-verification.json",
"rebuild-report.json",
})
_FAILURE_LAYERS = frozenset({
"model_format_or_decision",
"v3_contract_or_verifier",
"cdsl_expression",
"engine_execution",
"independent_visual_review",
"configuration_or_network",
})
_ERROR_FAILURE_LAYERS = {
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
"STALE_WORKING_HEAD": "model_format_or_decision",
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
"CANDIDATE_BUILD_FAILED": "engine_execution",
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
"STORAGE_FAILURE": "configuration_or_network",
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
"FAILED_INTERNAL": "engine_execution",
}
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
parser.add_argument("--require-live", action="store_true")
parser.add_argument("--allow-skip", action="store_true")
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.")
parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.")
parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.")
parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.")
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
return parser.parse_args()
def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]:
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
if fixture_name == "comprehensive.json":
source_document = str(value.get("source_document") or "")
expected_digest = str(value.get("source_document_sha256") or "")
source_path = (BACKEND_ROOT.parent / source_document).resolve()
workspace_root = BACKEND_ROOT.parent.resolve()
if (
not source_document
or workspace_root not in source_path.parents
or not source_path.is_file()
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
):
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
values = values[:2] if suite == "smoke" else values
if not scenario_ids:
return values
requested = list(dict.fromkeys(scenario_ids))
available = {str(item.get("id") or "") for item in values}
unknown = [scenario_id for scenario_id in requested if scenario_id not in available]
if unknown:
raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}")
selected = [item for item in values if str(item.get("id") or "") in set(requested)]
return selected
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
codes: list[str] = []
for event in events:
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
code = result.get("code") if isinstance(result, dict) else None
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
codes.append(str(code))
return codes
def _acceptance_coverage(
scenario: dict[str, Any],
requirements_contract: dict[str, Any] | None,
) -> dict[str, Any]:
"""Assess whether a fixture's acceptance contract is representable today.
Comprehensive prompts deliberately include manufacturing relationships that
may not yet have a deterministic verifier. A healthy-looking solid is
not evidence for those relationships, so they remain explicit capability
gaps instead of silently passing a scenario.
"""
actual = [
{
"claim_kind": str(claim.get("claim_kind") or ""),
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
}
for requirement in (requirements_contract or {}).get("requirements") or ()
if isinstance(requirement, dict)
for claim in requirement.get("acceptance_claims") or ()
if isinstance(claim, dict)
]
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
expected_claims = [
{
"claim_kind": str(item.get("claim_kind") or ""),
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
}
for item in scenario.get("required_claims") or ()
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
]
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
expected_claims.extend(
{"claim_kind": claim_kind, "expected": {}}
for claim_kind in sorted(required - expected_kinds)
)
gaps = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in scenario.get("validation_capability_gaps") or ()
if isinstance(item, dict)
]
missing = [
claim for claim in expected_claims
if not any(
actual_claim["claim_kind"] == claim["claim_kind"]
and _contains_business_expected(actual_claim["expected"], claim["expected"])
for actual_claim in actual
)
]
return {
"required_claim_kinds": sorted(required),
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
"required_claims": expected_claims,
"missing_claims": missing,
"validation_capability_gaps": gaps,
"complete": not missing and not gaps,
}
def _contains_business_expected(actual: Any, required: Any) -> bool:
"""Match fixture business values without coupling to verifier tolerances.
Tolerances are executable verifier parameters chosen within the schema's
safe range. They are not a separate user requirement and should not make
a valid generated contract fail release evaluation merely because the
author used the registry default instead of the fixture's tighter value.
"""
if isinstance(actual, dict) and isinstance(required, dict):
return all(
key in actual
and _contains_business_expected(actual[key], value)
for key, value in required.items()
if key not in {"tolerance_mm", "tolerance"}
)
return contains_expected(actual, required)
def _contains_expected(actual: Any, required: Any) -> bool:
"""Match canonical claim values in release evaluation."""
return contains_expected(actual, required)
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
"""Classify a failed conformance probe without confusing outages for gaps.
A provider transport failure means the probe did not establish either
support or non-support. Only a complete response that violates one of the
exposed tool contracts is evidence of a model structured-output limit.
"""
messages = [
str(failure.get("message") or "").casefold()
for report in reports
for failure in report.get("failures") or ()
if isinstance(failure, dict)
]
unavailable_markers = (
"transport unavailable",
"connection",
"network",
"timeout",
"timed out",
"temporarily unavailable",
)
if any(any(marker in message for marker in unavailable_markers) for message in messages):
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
def _artifact_evidence_complete(
artifact_root: Path,
revisions: list[str],
active_revision: str,
artifact_manifest: dict[str, Any] | None,
ledger: list[dict[str, Any]],
) -> bool:
"""Require every published CAD decision to retain its reviewable evidence.
A non-empty report manifest is not enough: a task could otherwise report
only a rendered contract view while silently losing the STEP, topology, or
node verification used to accept a revision. Checkpoint manifests protect
immutable build inputs and outputs; the report manifest additionally
protects the frozen contract and final independent review written later.
"""
if not revisions or not active_revision:
return False
report_files = {
str(item.get("path") or "")
for item in (artifact_manifest or {}).get("files") or ()
if isinstance(item, dict)
}
required_report_files = {
"requirements-contract.json",
f"reviews/final/{active_revision}/final-review.json",
}
if artifact_manifest is not None and not required_report_files.issubset(report_files):
return False
if not all((artifact_root / path).is_file() for path in required_report_files):
return False
feature_revisions = {
str(item.get("revision_id") or "")
for item in ledger
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
}
for revision_id in sorted(set(revisions)):
revision_root = artifact_root / "revisions" / revision_id
evidence_files = (
_FEATURE_NODE_EVIDENCE_FILES
if revision_id in feature_revisions
else _LEGACY_CANDIDATE_EVIDENCE_FILES
)
required_paths = {revision_root / relative for relative in evidence_files}
manifest_path = revision_root / "manifest.json"
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
return False
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
declared = manifest.get("files") if isinstance(manifest, dict) else None
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
return False
for relative, digest in declared.items():
path = revision_root / str(relative)
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
return False
if sha256(path.read_bytes()).hexdigest() != digest:
return False
if artifact_manifest is not None:
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
if not expected_report_paths.issubset(report_files):
return False
return True
def _failure_attribution(
*,
checks: dict[str, bool],
outcome: str,
events: list[dict[str, Any]],
projection: dict[str, Any],
) -> dict[str, Any] | None:
"""Return one evidence-backed failure layer required by the test target."""
if outcome == "passed":
return None
codes: list[str] = []
for event in events:
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
code = result.get("code") if isinstance(result, dict) else ""
if isinstance(code, str) and code:
codes.append(code)
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
if isinstance(last_error, str) and last_error:
codes.append(last_error)
for code in reversed(codes):
layer = _ERROR_FAILURE_LAYERS.get(code)
if layer:
return {
"layer": layer,
"reason_code": code,
"evidence_codes": list(dict.fromkeys(codes)),
"failed_checks": [name for name, passed in checks.items() if not passed],
}
failed_checks = [name for name, passed in checks.items() if not passed]
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
elif "raw_argument_audit" in failed_checks:
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
else:
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
return {
"layer": layer,
"reason_code": reason,
"evidence_codes": list(dict.fromkeys(codes)),
"failed_checks": failed_checks,
}
def _run_checks(
scenario: dict[str, Any],
projection: dict[str, Any],
usage: dict[str, Any],
requirements_contract: dict[str, Any] | None,
events: list[dict[str, Any]],
artifact_root: Path,
ledger: list[dict[str, Any]] | None = None,
tool_audits: list[dict[str, Any]] | None = None,
artifact_manifest: dict[str, Any] | None = None,
) -> dict[str, bool]:
records = usage.get("records") if isinstance(usage.get("records"), list) else []
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
published = [
item for item in audit_ledger
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
]
revisions = [str(item.get("revision_id") or "") for item in published]
scheduled_atomic_ids = {
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
for item in audit_ledger
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
}
operation_ids = {
str(
item.get("actual_atomic_id")
or item.get("atomic_id")
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
)
for item in published
}
required_operation_groups = [
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
for group in scenario.get("required_any_atomic_id_groups") or ()
if isinstance(group, list)
]
active_revision = str(projection.get("active_revision") or "")
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
audit_records = tool_audits if isinstance(tool_audits, list) else []
audit_valid = bool(audit_records) and all(
isinstance(item, dict)
and isinstance(item.get("raw_arguments_hash"), str)
and len(item["raw_arguments_hash"]) == 64
and item.get("canonical_schema_valid") is True
and isinstance(item.get("state_binding"), dict)
and bool(item["state_binding"].get("phase"))
and item["state_binding"].get("binding_valid") is True
and item.get("single_allowed_call") is True
for item in audit_records
)
acceptance = _acceptance_coverage(scenario, requirements_contract)
artifact_evidence = _artifact_evidence_complete(
artifact_root,
revisions,
active_revision,
artifact_manifest,
audit_ledger,
)
return {
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
"call_budget": len(records) <= int(scenario["max_total_calls"]),
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
"required_claims": not acceptance["missing_claims"],
"acceptance_contract_coverage": acceptance["complete"],
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
"required_operation_alternative": (
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
and all(group.intersection(operation_ids) for group in required_operation_groups)
),
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
"required_artifact_evidence": artifact_evidence,
"artifact_manifest": (
artifact_manifest is None
or (
isinstance(artifact_manifest.get("files"), list)
and bool(artifact_manifest["files"])
and all(
isinstance(item, dict)
and isinstance(item.get("path"), str)
and isinstance(item.get("sha256"), str)
and len(item["sha256"]) == 64
for item in artifact_manifest["files"]
)
)
),
"action_ledger": jsonl.is_file(),
"unique_revisions": len(revisions) == len(set(revisions)),
"deterministic_claims_pass": bool(final_claims) and all(
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
for item in final_claims
),
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
"no_schema_or_decision_rejections": not _rejection_codes(events),
}
def _git_revision() -> str:
try:
completed = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
text=True, check=True, timeout=5,
)
return completed.stdout.strip()
except (OSError, subprocess.SubprocessError):
return "unknown"
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Persist event metadata without prompts, tool arguments, or tool facts."""
audit: list[dict[str, Any]] = []
for item in events:
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
audit.append({
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
})
return audit
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return report-safe audit metadata without argument values or prompts."""
return [
{
"audit_id": item.get("audit_id"),
"at": item.get("at"),
"actor": item.get("actor"),
"tool": item.get("tool"),
"raw_arguments_hash": item.get("raw_arguments_hash"),
"canonical_schema_valid": item.get("canonical_schema_valid"),
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
"returned_tool": item.get("returned_tool"),
"single_allowed_call": item.get("single_allowed_call"),
}
for item in audits
if isinstance(item, dict)
]
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Expose audit identifiers explicitly without copying provider payloads."""
candidate_ids = sorted({
str(item.get("candidate_id"))
for item in ledger
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
})
revision_ids = sorted({
str(item.get("revision_id"))
for item in ledger
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
})
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
for item in reversed(ledger):
results = item.get("claim_results")
if item.get("event") == "completed" and isinstance(results, list):
return [value for value in results if isinstance(value, dict)]
return []
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
"""Keep run linkage without publishing task or provider correlation values."""
return [
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
for item in invocations
if isinstance(item, dict) and item.get("invocation_id")
]
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
"""Hash reviewable CAD evidence without copying source or prompt content."""
allowed_exact = {
"requirements-contract.json",
"requirements.md",
"completion-target.md",
"completion-result.md",
}
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
files: list[dict[str, str]] = []
if artifact_root.is_dir():
for path in sorted(artifact_root.rglob("*")):
if not path.is_file():
continue
relative = path.relative_to(artifact_root).as_posix()
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
continue
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]:
"""Summarize author-only guidance audit metadata without retaining prompts."""
records = [
item for item in usage.get("records") or ()
if isinstance(item, dict) and item.get("role") != "reviewer"
]
sections = sorted({
section_id
for item in records
for section_id in item.get("guidance_section_ids") or ()
if isinstance(section_id, str)
})
versions = sorted({
str(item.get("guidance_version") or "")
for item in records
if str(item.get("guidance_version") or "")
})
return {
"enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records),
"versions": versions,
"section_ids": sections,
"chars_total": sum(int(item.get("guidance_chars") or 0) for item in records),
"fallback_reasons": sorted({
str(item.get("guidance_fallback_reason") or "")
for item in records
if str(item.get("guidance_fallback_reason") or "")
}),
}
def _has_unsupported_capability(row: dict[str, Any]) -> bool:
"""Recognize engine-declared unsupported capability without hiding model errors."""
for event in row.get("ledger") or ():
if not isinstance(event, dict):
continue
for failure in event.get("operation_failures") or ():
if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""):
return True
if "unsupported_" in str(event.get("message") or ""):
return True
return False
def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
results = [item for item in report.get("results") or () if isinstance(item, dict)]
capability_gaps = [
item for item in results
if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item)
]
return [item for item in results if item not in capability_gaps], capability_gaps
def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
if not rows:
return {"eligible_runs": 0}
author_calls = [
sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
context_chars = [
sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
prompt_tokens = [
sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer")
for row in rows
]
failure_layers: dict[str, int] = {}
for row in rows:
layer = str((row.get("failure_attribution") or {}).get("layer") or "passed")
failure_layers[layer] = failure_layers.get(layer, 0) + 1
def rate(predicate: Any) -> float:
return sum(1 for row in rows if predicate(row)) / len(rows)
return {
"eligible_runs": len(rows),
"executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))),
"completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"),
"deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))),
"schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows),
"cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"),
"median_author_calls": median(author_calls),
"median_author_context_chars": median(context_chars),
"total_author_prompt_tokens": sum(prompt_tokens),
"median_author_prompt_tokens": median(prompt_tokens),
"failure_layers": dict(sorted(failure_layers.items())),
}
def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]:
"""Compare paired guidance-off/on runs without treating engine gaps as prompt results."""
control_rows, control_gaps = _guidance_metric_rows(control)
treatment_rows, treatment_gaps = _guidance_metric_rows(treatment)
control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows}
treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows}
paired = sorted(set(control_by_key).intersection(treatment_by_key))
control_only = sorted(set(control_by_key).difference(treatment_by_key))
treatment_only = sorted(set(treatment_by_key).difference(control_by_key))
control_pairs = [control_by_key[key] for key in paired]
treatment_pairs = [treatment_by_key[key] for key in paired]
control_metrics = _guidance_metrics(control_pairs)
treatment_metrics = _guidance_metrics(treatment_pairs)
same_runtime = (
control.get("author") == treatment.get("author")
and control.get("reviewer") == treatment.get("reviewer")
and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256")
and control.get("operation_contracts") == treatment.get("operation_contracts")
)
control_guidance = bool((control.get("author_guidance") or {}).get("enabled"))
treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled"))
same_budgets = all(
control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget")
for key in paired
)
calls_control = control_metrics.get("median_author_calls")
calls_treatment = treatment_metrics.get("median_author_calls")
calls_within_limit = (
isinstance(calls_control, (int, float))
and isinstance(calls_treatment, (int, float))
and calls_treatment <= calls_control * 1.10
)
improved = (
treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0)
or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0)
)
gates = {
"complete_pairing": bool(paired) and not control_only and not treatment_only,
"control_off_treatment_on": not control_guidance and treatment_guidance,
"same_author_reviewer_runtime_and_contracts": same_runtime,
"same_per_scenario_budgets": same_budgets,
"checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0),
"completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0),
"median_author_calls_within_ten_percent": calls_within_limit,
"model_or_cdsl_failure_improved": improved,
}
return {
"schema_version": "cad.author-guidance-comparison.v1",
"status": "passed" if all(gates.values()) else "failed",
"gates": gates,
"paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired],
"unpaired_runs": {
"control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only],
"treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only],
},
"control": control_metrics,
"treatment": treatment_metrics,
"excluded_capability_gaps": {
"control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps],
"treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps],
},
}
def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]:
try:
control = json.loads(control_path.read_text(encoding="utf-8"))
treatment = json.loads(treatment_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"}
if not isinstance(control, dict) or not isinstance(treatment, dict):
return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"}
return compare_guidance_reports(control, treatment)
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
try:
scenarios = _fixture(arguments.suite, arguments.scenarios)
except ValueError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1
if repetitions < 1:
return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"}
settings = get_settings()
if arguments.author_guidance is not None:
settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on")
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)
if review_provider.id == author_provider.id and review_model.id == author_model.id:
raise ValueError("Reviewer must differ from author")
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
if arguments.suite == "release" and baseline_path is None:
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
try:
baseline = load_token_baseline(baseline_path) if baseline_path else None
current_author_identity = author_request_identity(author_provider, author_model)
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
except TokenBaselineError as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
if baseline is not None:
baseline_errors = validate_token_baseline_provenance(
baseline,
scenarios=scenarios,
author_identity=current_author_identity,
runtime_profile_hash=runtime_profile_hash,
)
if baseline_errors:
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
services = compose_v3(isolated)
contracts = [
{
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
}
for atomic_id in services.workflow.runtime.supported_atomic_ids()
]
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
try:
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
except Exception as error:
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
return {
"status": "LIVE_EVAL_BLOCKED",
"error": error,
"failure_layer": failure_layer,
"author_capability": author_capability,
"reviewer_capability": reviewer_capability,
}
results: list[dict[str, Any]] = []
for scenario in scenarios:
for repetition in range(1, repetitions + 1):
services.workflow.config = replace(
services.workflow.config,
# State transitions include local candidate recovery, so the
# loop guard is deliberately independent from the externally
# measured model-call budgets below.
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
max_author_turns=int(scenario["max_author_turns"]),
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
max_model_calls=int(scenario["max_total_calls"]),
)
task_id = f"cad_{secrets.token_hex(6)}"
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
if oracle_claims:
services.workflow.requirements.register_evaluation_contract_oracle(
task_id,
oracle_claims,
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
)
services.workflow.create_task(task_id, str(scenario["request"]))
events: list[dict[str, Any]] = []
started = datetime.now(timezone.utc)
try:
async with asyncio.timeout(int(scenario["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"}})
projection = services.repository.get_task_projection(task_id) or {}
usage = services.repository.usage_summary(task_id)
ledger = services.repository.ledger_events(task_id)
invocations = services.repository.invocation_records(task_id)
tool_audits = services.repository.tool_audits(task_id)
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
artifact_root = (isolated.task_root / task_id).resolve()
artifact_manifest = _safe_artifact_manifest(artifact_root)
finished = datetime.now(timezone.utc)
checks = _run_checks(
scenario,
projection,
usage,
services.artifacts.read_requirements_contract(
task_id,
services.repository.get_state(task_id).requirements_contract_path
if services.repository.get_state(task_id) is not None
else "",
),
events,
artifact_root,
ledger,
tool_audits,
artifact_manifest,
)
success = all(checks.values())
acceptance = _acceptance_coverage(
scenario,
services.artifacts.read_requirements_contract(
task_id,
services.repository.get_state(task_id).requirements_contract_path
if services.repository.get_state(task_id) is not None
else "",
),
)
outcome = (
"passed"
if success
else "validation_capability_gap"
if not acceptance["complete"]
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
else "failed"
)
failure_attribution = _failure_attribution(
checks=checks,
outcome=outcome,
events=events,
projection=projection,
)
results.append({
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
"duration_ms": round((finished - started).total_seconds() * 1000),
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
"acceptance_coverage": acceptance,
"failure_attribution": failure_attribution,
"guidance": _guidance_metadata(usage),
"scenario_budget": {
"max_author_turns": int(scenario["max_author_turns"]),
"max_reviewer_turns": int(scenario["max_reviewer_turns"]),
"max_total_calls": int(scenario["max_total_calls"]),
"max_total_tokens": int(scenario["max_total_tokens"]),
},
"rejection_codes": _rejection_codes(events),
"schema_rejection_count": len(_rejection_codes(events)),
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
**_ledger_identifiers(ledger),
"final_claim_results": _final_claim_results(ledger),
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
"tool_audits": _report_tool_audits(tool_audits),
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
"artifact_manifest": artifact_manifest,
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
})
token_comparison = (
compare_token_baseline(
baseline,
scenarios=scenarios,
v3_results=results,
author_identity=current_author_identity,
runtime_profile_hash=runtime_profile_hash,
)
if baseline is not None
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
)
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
return {
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
"author": {"provider": author_provider.id, "model": author_model.id},
"author_request_identity": current_author_identity,
"reviewer": {"provider": review_provider.id, "model": review_model.id},
"author_guidance": {
"enabled": settings.agent_author_guidance_enabled,
"max_chars": settings.agent_author_guidance_max_chars,
},
"author_capability": author_capability,
"reviewer_capability": reviewer_capability,
"structured_output_mode": {
"author": str(author_capability.get("mode") or ""),
"reviewer": str(reviewer_capability.get("mode") or ""),
},
"git_revision": _git_revision(),
"protocol_version": "3.2",
"runtime_profile_sha256": runtime_profile_hash,
"operation_contracts": contracts,
"verifier_registry_version": "cad.verifier-registry.v1",
"verifier_schema_hash": verifier_schema_hash,
"token_comparison": token_comparison,
"baseline_report": str(baseline_path) if baseline_path else "",
"repetitions": repetitions,
"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 = (
compare_guidance_report_paths(*arguments.compare_guidance_reports)
if arguments.compare_guidance_reports
else asyncio.run(_run(arguments, report_root))
)
except KeyboardInterrupt:
# Let an explicit operator interruption retain its normal CLI
# semantics. An external kill cannot be reported reliably either.
raise
except BaseException as error:
# A live evaluation may fail before it creates a task (for example
# during conformance). Its report is still the release gate's audit
# artifact, so an unexpected evaluator failure must not disappear.
result = {
"status": "LIVE_EVAL_BLOCKED",
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
"failure_layer": "configuration_or_network",
}
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
result["skip_reason"] = result.get("error", "live provider access is unavailable")
result["status"] = "skipped"
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
if result["status"] == "passed":
return 0
if result["status"] == "skipped":
return 0
return 2
if __name__ == "__main__":
sys.exit(main())
@@ -1,90 +0,0 @@
"""Advance one persisted event for a task in an isolated live-evaluation root.
Long real-provider evaluations can outlive a command host's execution window.
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
closes the async generator after that event has been durably handled. Repeated
invocations therefore resume the same task without re-running already
persisted nodes.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
import json
from pathlib import Path
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 get_settings
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
parser.add_argument("--report-root", required=True, type=Path)
parser.add_argument("--task-id", required=True)
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
return parser.parse_args()
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
settings = get_settings()
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)
report_root = arguments.report_root.resolve()
services = compose_v3(replace(
settings,
task_root=report_root / "artifacts",
conversation_root=report_root / "conversations",
))
before = services.repository.get_state(arguments.task_id)
if before is None:
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
runner = services.workflow.run(
task_id=arguments.task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
)
try:
name, payload = await anext(runner)
except StopAsyncIteration:
name, payload = "workflow_exhausted", {}
finally:
await runner.aclose()
after = services.repository.get_state(arguments.task_id)
return {
"task_id": arguments.task_id,
"event": {"name": name, "payload": payload},
"before": {"phase": before.phase.value, "version": before.version},
"after": {
"phase": after.phase.value if after is not None else "",
"version": after.version if after is not None else -1,
"active_revision": after.active_revision if after is not None else "",
"last_error": after.last_error.value if after is not None and after.last_error else "",
},
}
def main() -> int:
arguments = _arguments()
try:
result = asyncio.run(_advance(arguments))
except BaseException as error:
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
print(json.dumps(result, ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,55 @@
"""Aggregate local results for the `cad.single-stage.v1` evaluation suite.
The evaluator accepts already-recorded task projections and never calls a
model. This keeps rate reporting reproducible and separates it from live
provider experiments.
"""
from __future__ import annotations
from typing import Any, Iterable
def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
"""Return first-pass, final, compliance, and cost counters.
A record may contain ``attempts`` with ``schema_valid`` and
``executable`` booleans, final lifecycle/revision information, requirement
target states, usage records, and an elapsed duration in milliseconds.
Missing fields are reported as zero rather than guessed.
"""
values = [item for item in records if isinstance(item, dict)]
first_schema = first_executable = final_executable = 0
targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")}
calls = prompt_tokens = completion_tokens = duration_ms = 0
for record in values:
attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else []
first = attempts[0] if attempts and isinstance(attempts[0], dict) else {}
first_schema += int(bool(first.get("schema_valid")))
first_executable += int(bool(first.get("executable")))
final_executable += int(bool(record.get("published_revision") or record.get("active_revision")))
for target in record.get("requirement_targets") or ():
state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending"
targets[state if state in targets else "pending"] += 1
usage = record.get("usage") if isinstance(record.get("usage"), dict) else {}
usage_records = usage.get("records") if isinstance(usage.get("records"), list) else []
calls += len(usage_records)
for item in usage_records:
if not isinstance(item, dict):
continue
prompt_tokens += int(item.get("prompt_tokens") or 0)
completion_tokens += int(item.get("completion_tokens") or 0)
duration_ms += int(record.get("duration_ms") or 0)
total = len(values)
rate = lambda count: count / total if total else 0.0
return {
"schema_version": "cad.single-stage.eval-summary.v1",
"tasks": total,
"first_pass_schema_rate": rate(first_schema),
"first_pass_executable_rate": rate(first_executable),
"final_executable_rate": rate(final_executable),
"requirement_targets": targets,
"calls": calls,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"duration_ms": duration_ms,
}
@@ -1,297 +0,0 @@
"""Provenance-checked token baseline comparison for the v3 release gate.
The target intentionally does not accept estimated tokens or a hand-written
percentage. A v2 run must record the same fixed requests and author request
configuration before it can be compared with a v3 release report.
"""
from __future__ import annotations
from hashlib import sha256
import json
from pathlib import Path
from statistics import median
from typing import Any
from app.settings import ProviderConfig, ProviderModel
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
BASELINE_PROTOCOL_VERSION = "2.0"
MINIMUM_REPETITIONS = 3
TOKEN_REDUCTION_TARGET = 0.30
MEASUREMENTS = {"prompt_tokens", "context_chars"}
class TokenBaselineError(ValueError):
"""A baseline cannot prove the release token target."""
def request_sha256(request: str) -> str:
return sha256(request.encode("utf-8")).hexdigest()
def profile_sha256(profile_path: Path) -> str:
try:
return sha256(profile_path.read_bytes()).hexdigest()
except OSError as error:
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
"""Capture every author setting that can affect prompt-token comparison."""
return {
"provider": provider.id,
"model": model.id,
"api_style": provider.api_style,
"reasoning_effort": provider.reasoning_effort,
# Chat Completions is explicitly deterministic in StructuredModelGateway.
# Responses models use their configured/provider-default sampling mode.
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
}
def load_token_baseline(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
if not isinstance(payload, dict):
raise TokenBaselineError("Token baseline report must be a JSON object.")
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
if payload.get("measurement") not in MEASUREMENTS:
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
return payload
def validate_token_baseline_provenance(
baseline: dict[str, Any],
*,
scenarios: list[dict[str, Any]],
author_identity: dict[str, Any],
runtime_profile_hash: str,
) -> list[str]:
"""Reject an incomparable baseline before any billable v3 API call."""
errors: list[str] = []
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
errors.append("protocol_version must equal 2.0")
if baseline.get("author") != author_identity:
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
errors.append("runtime profile hash does not match the v3 release")
measurement = str(baseline.get("measurement") or "")
if measurement not in MEASUREMENTS:
errors.append("measurement must be prompt_tokens or context_chars")
baseline_scenarios = baseline.get("scenarios")
if not isinstance(baseline_scenarios, list):
return [*errors, "scenarios must be an array"]
by_scenario = {
str(item.get("scenario") or ""): item
for item in baseline_scenarios
if isinstance(item, dict) and item.get("scenario")
}
for scenario in scenarios:
scenario_id = str(scenario.get("id") or "")
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
baseline_scenario = by_scenario.get(scenario_id)
if not isinstance(baseline_scenario, dict):
errors.append(f"missing baseline scenario {scenario_id}")
continue
if baseline_scenario.get("request_sha256") != expected_request_hash:
errors.append(f"baseline request hash differs for {scenario_id}")
repetitions = baseline_scenario.get("repetitions")
if not isinstance(repetitions, list):
errors.append(f"baseline {scenario_id} repetitions must be an array")
continue
by_repetition = {
int(item.get("repetition")): item
for item in repetitions
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
}
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
if not expected_repetitions.issubset(by_repetition):
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
for repetition in expected_repetitions:
run = by_repetition[repetition]
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
if not isinstance(run.get(field), bool):
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
return errors
def compare_token_baseline(
baseline: dict[str, Any],
*,
scenarios: list[dict[str, Any]],
v3_results: list[dict[str, Any]],
author_identity: dict[str, Any],
runtime_profile_hash: str,
) -> dict[str, Any]:
"""Return a complete, fail-closed comparison for the release report."""
errors = validate_token_baseline_provenance(
baseline, scenarios=scenarios, author_identity=author_identity,
runtime_profile_hash=runtime_profile_hash,
)
measurement = str(baseline.get("measurement") or "")
baseline_scenarios = baseline.get("scenarios")
if not isinstance(baseline_scenarios, list):
errors.append("scenarios must be an array")
baseline_scenarios = []
by_scenario = {
str(item.get("scenario") or ""): item
for item in baseline_scenarios
if isinstance(item, dict) and item.get("scenario")
}
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
for result in v3_results:
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
current_by_scenario.setdefault(result["scenario"], []).append(result)
baseline_tokens: list[int] = []
current_tokens: list[int] = []
baseline_completed: list[bool] = []
current_completed: list[bool] = []
baseline_final_review: list[bool] = []
current_final_review: list[bool] = []
baseline_deterministic: list[bool] = []
current_deterministic: list[bool] = []
per_scenario: list[dict[str, Any]] = []
for scenario in scenarios:
scenario_id = str(scenario.get("id") or "")
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
baseline_scenario = by_scenario.get(scenario_id)
if not isinstance(baseline_scenario, dict):
errors.append(f"missing baseline scenario {scenario_id}")
continue
if baseline_scenario.get("request_sha256") != expected_request_hash:
errors.append(f"baseline request hash differs for {scenario_id}")
continue
repetitions = baseline_scenario.get("repetitions")
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
continue
current = current_by_scenario.get(scenario_id, [])
if len(current) < MINIMUM_REPETITIONS:
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
continue
baseline_by_repetition = {
int(item.get("repetition")) : item
for item in repetitions
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
}
current_by_repetition = {
int(item.get("repetition")): item
for item in current
if isinstance(item.get("repetition"), int)
}
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
if not expected_repetitions.issubset(baseline_by_repetition):
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
if not expected_repetitions.issubset(current_by_repetition):
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
continue
scenario_baseline_tokens: list[int] = []
scenario_current_tokens: list[int] = []
for repetition in sorted(expected_repetitions):
baseline_run = baseline_by_repetition[repetition]
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
if author_tokens is None or plan_review_tokens is None:
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
continue
current_run = current_by_repetition[repetition]
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
v3_author_tokens = _v3_author_metric(usage, measurement)
if v3_author_tokens is None:
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
continue
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
scenario_current_tokens.append(v3_author_tokens)
baseline_completed.append(bool(baseline_run.get("completed")))
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
baseline_tokens.extend(scenario_baseline_tokens)
current_tokens.extend(scenario_current_tokens)
per_scenario.append({
"scenario": scenario_id,
"baseline_median_metric": median(scenario_baseline_tokens),
"v3_median_author_metric": median(scenario_current_tokens),
})
baseline_median = median(baseline_tokens) if baseline_tokens else None
current_median = median(current_tokens) if current_tokens else None
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
baseline_valid = not errors
checks = {
"baseline_valid": baseline_valid,
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
"plan_review_calls_removed": baseline_valid and all(
not str(record.get("tool") or "").startswith("modeling_plan")
for result in v3_results if isinstance(result, dict)
for record in ((result.get("usage") or {}).get("records") or [])
if isinstance(record, dict)
),
}
return {
"schema_version": "cad.token-comparison.v1",
"baseline_protocol_version": baseline.get("protocol_version"),
"measurement": measurement,
"baseline_median_metric": baseline_median,
"v3_median_author_metric": current_median,
"median_metric_reduction": reduction,
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
"baseline_completion_rate": _rate(baseline_completed),
"v3_completion_rate": _rate(current_completed),
"baseline_final_review_rate": _rate(baseline_final_review),
"v3_final_review_rate": _rate(current_final_review),
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
"v3_deterministic_claim_rate": _rate(current_deterministic),
"per_scenario": per_scenario,
"errors": errors,
"checks": checks,
}
def _non_negative_int(value: Any) -> int | None:
return value if isinstance(value, int) and value >= 0 else None
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
records = usage.get("records")
if not isinstance(records, list):
return None
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
if not author_records:
return None
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
return None
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
values = [
_non_negative_int(item.get(field))
for item in author_records
]
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
def _rate(values: list[bool]) -> float | None:
return sum(values) / len(values) if values else None
-164
View File
@@ -1,164 +0,0 @@
"""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())
+6 -78
View File
@@ -5,60 +5,13 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Protocol from typing import Any, Protocol
from .domain.state import TaskPhase, TaskState from .domain.state import TaskState
class AdapterUnavailable(RuntimeError): class AdapterUnavailable(RuntimeError):
"""A bounded external-service outage; handlers must preserve checkpoints.""" """A bounded external-service outage; handlers must preserve checkpoints."""
@dataclass(frozen=True, slots=True)
class AuthorGuidanceSelection:
"""Non-authoritative author context selected from the local guidance corpus."""
version: str = ""
section_ids: tuple[str, ...] = ()
content: str = ""
enabled: bool = False
fallback_reason: str = ""
def usage_metadata(self) -> dict[str, object]:
return {
"guidance_version": self.version,
"guidance_section_ids": list(self.section_ids),
"guidance_chars": len(self.content),
"guidance_enabled": self.enabled,
"guidance_fallback_reason": self.fallback_reason,
}
class AuthorGuidance(Protocol):
"""Select bounded local author guidance without interpreting user intent."""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection: ...
class NullAuthorGuidance:
"""Compatibility default that retains the pre-guidance author prompt."""
def select(
self,
*,
phase: TaskPhase,
atomic_id: str,
repair_required: bool,
supported_atomic_ids: tuple[str, ...],
) -> AuthorGuidanceSelection:
return AuthorGuidanceSelection(fallback_reason="guidance_not_configured")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class InvocationRecord: class InvocationRecord:
invocation_id: str invocation_id: str
@@ -68,7 +21,7 @@ class InvocationRecord:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CandidateStage: class StagingRevision:
stage_id: str stage_id: str
output_dir: str output_dir: str
@@ -106,38 +59,21 @@ class TaskRepository(Protocol):
class ArtifactStore(Protocol): class ArtifactStore(Protocol):
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ... def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
def read_source_index(self, task_id: str) -> dict[str, str]: ...
def read_source_requirements(self, task_id: str) -> str: ... def read_source_requirements(self, task_id: str) -> str: ...
def source_image_paths(self, task_id: str) -> list[str]: ... def source_image_paths(self, task_id: str) -> list[str]: ...
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ... def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ... def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ... def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ...
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ... def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
class CadRuntime(Protocol): class CadRuntime(Protocol):
def supported_atomic_ids(self) -> tuple[str, ...]: ... def supported_atomic_ids(self) -> tuple[str, ...]: ...
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ... def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ... def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: ...
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, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
def render_review_bundle(self, output_dir: str) -> 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]]]: ... 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]]]: ...
@@ -146,13 +82,5 @@ class ModelGateway(Protocol):
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ... async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
class ReviewGateway(Protocol):
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
class VerifierExecutor(Protocol):
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
class EventPublisher(Protocol): class EventPublisher(Protocol):
async def publish(self, event: dict[str, Any]) -> None: ... async def publish(self, event: dict[str, Any]) -> None: ...
+22 -123
View File
@@ -7,13 +7,11 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch from app.models.contracts import ChatRequest, ConversationPatch
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
from app.services.agent_service import AgentService from app.services.agent_service import AgentService
from app.services.library import CdslLibrary from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
from app.services.attachments import attachment_record, classify_upload, extract_document_text from app.services.attachments import attachment_record, classify_upload, extract_document_text
from app.services.image_processing import image_metadata from app.services.image_processing import image_metadata
from app.services.review_renderer import renderer_status
from app.settings import get_settings from app.settings import get_settings
@@ -57,12 +55,6 @@ async def config() -> dict[str, Any]:
for model in provider.models for model in provider.models
], ],
}) })
try:
settings.resolve_review_model()
renderer_ready, renderer_detail = renderer_status()
review_error = "" if renderer_ready else renderer_detail
except ValueError as error:
review_error = str(error)
return { return {
"default_provider": settings.default_provider_id, "default_provider": settings.default_provider_id,
"default_model": settings.llm_model, "default_model": settings.llm_model,
@@ -71,8 +63,6 @@ async def config() -> dict[str, Any]:
"configured": settings.llm_configured, "configured": settings.llm_configured,
"library_samples": library.count(), "library_samples": library.count(),
"autonomous_generation": settings.autonomous_generation, "autonomous_generation": settings.autonomous_generation,
"review_configured": not review_error,
"review_error": review_error,
} }
@@ -123,7 +113,7 @@ async def upload_conversation_attachment(
if current is None: if current is None:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
active_task_id = str(current.get("current_task_id") or "") active_task_id = str(current.get("current_task_id") or "")
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None
if str((active_task or {}).get("lifecycle") or "") == "running": if str((active_task or {}).get("lifecycle") or "") == "running":
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
kind = classify_upload(filename, file.content_type or "", len(data)) kind = classify_upload(filename, file.content_type or "", len(data))
@@ -145,101 +135,25 @@ async def upload_conversation_attachment(
async def read_task(task_id: str) -> JSONResponse: async def read_task(task_id: str) -> JSONResponse:
try: try:
safe_id = safe_task_id(task_id) safe_id = safe_task_id(task_id)
task = agent.v3.repository.get_task_projection(safe_id) task = agent.cad.repository.get_task_projection(safe_id)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
if task is None: if task is None:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") task["preview_revision"] = str(task.get("active_revision") or "")
state = agent.v3.repository.get_state(safe_id) state = agent.cad.repository.get_state(safe_id)
task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path 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["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"]) task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
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["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
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 result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md" task["usage"] = agent.cad.repository.usage_summary(safe_id)
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["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
if isinstance(task["feature_plan"], dict):
plan = FeaturePlan.model_validate(task["feature_plan"])
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
task["feature_nodes"] = [
{
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
**node_evidence.get(str(node.get("node_id") or ""), {}),
}
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
]
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)
return JSONResponse(task) return JSONResponse(task)
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
"""Project frozen claims with the newest committed verification evidence.
This is API-only data derived from SQLite-backed ledger entries and the
state-referenced frozen contract. It never parses Markdown or exposes a
staged candidate as a task checkpoint.
"""
latest: dict[str, dict[str, Any]] = {}
for event in reversed(ledger if isinstance(ledger, list) else []):
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
continue
values = event.get("claim_results")
if not isinstance(values, list):
values = event.get("coverage")
if not isinstance(values, list):
continue
for value in values:
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
latest[value["claim_id"]] = value
result: list[dict[str, Any]] = []
for requirement in (contract or {}).get("requirements") or ():
if not isinstance(requirement, dict):
continue
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
continue
evidence = latest.get(claim["claim_id"], {})
result.append({
"requirement_id": str(requirement.get("requirement_id") or ""),
"claim_id": claim["claim_id"],
"claim_kind": str(claim.get("claim_kind") or ""),
"deterministic": claim.get("verification_mode") == "deterministic",
"status": str(evidence.get("status") or "pending"),
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
})
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}") @app.delete("/v1/tasks/{task_id}")
async def cancel_task(task_id: str) -> JSONResponse: async def cancel_task(task_id: str) -> JSONResponse:
try: try:
@@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
try: try:
safe_id = safe_task_id(task_id) safe_id = safe_task_id(task_id)
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path) path = agent.cad.artifacts.artifact_path(safe_id, artifact_path)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
if not path.is_file(): if not path.is_file():
raise HTTPException(status_code=404, detail="Artifact not found") raise HTTPException(status_code=404, detail="Artifact not found")
task = agent.v3.repository.get_task_projection(safe_id) or {} task = agent.cad.repository.get_task_projection(safe_id) or {}
parts = artifact_path.split("/") parts = artifact_path.split("/")
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else "" published_revision = str(task.get("published_revision") or "")
active_revision = str(task.get("active_revision") or task.get("current_revision") or "") active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
if not revision_id: if not revision_id:
# The task directory also contains candidate staging, agent audit and if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
# frozen-input files. None of those are a public artifact surface. return FileResponse(path, filename=path.name)
raise HTTPException(status_code=403, detail="This task artifact is not public") raise HTTPException(status_code=403, detail="This task artifact is not public")
if revision_id == published_revision: if revision_id == published_revision:
@@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
f"revisions/{revision_id}/model.step", f"revisions/{revision_id}/model.step",
f"revisions/{revision_id}/model.glb", f"revisions/{revision_id}/model.glb",
f"revisions/{revision_id}/rebuild-report.json", f"revisions/{revision_id}/rebuild-report.json",
f"revisions/{revision_id}/build-diagnostics.json",
f"revisions/{revision_id}/renders/render-manifest.json",
} }
if artifact_path not in published_paths: if artifact_path not in published_paths:
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable") raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
return FileResponse(path, filename=path.name) return FileResponse(path, filename=path.name)
v32_checkpoint_paths = { # A running task can render its active executable prefix, but its staging
f"revisions/{revision_id}/model.cdsl.json", # inputs and diagnostics remain private until publication.
f"revisions/{revision_id}/model.step",
f"revisions/{revision_id}/model.glb",
f"revisions/{revision_id}/rebuild-report.json",
}
verified_revisions = {
str(item.get("revision_id") or "")
for item in task.get("revisions") or ()
if isinstance(item, dict) and item.get("status") == "success"
}
# Each v3.2 node revision is immutable and manifest-published. Make those
# checkpoints inspectable from the DAG while keeping every staging input,
# rejected candidate and arbitrary task artifact private.
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
# A legacy running task may render its active checkpoint in the browser,
# but cannot expose any other checkpoint artifact or failed-task preview.
if ( if (
str(task.get("lifecycle") or "") != "running" str(task.get("lifecycle") or "") != "running"
or revision_id != active_revision or revision_id != active_revision
+105 -440
View File
@@ -1,5 +1,4 @@
"""HTTP/SSE delivery adapter for the protocol v3 workflow.""" """HTTP/SSE delivery adapter for the single-stage CAD protocol."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -10,8 +9,7 @@ import secrets
from typing import Any from typing import Any
from app.cad_agent.application.workflow import ModelIdentity from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability from app.cad_agent.composition import CadServices, compose_cad_services
from app.cad_agent.composition import V3Services, compose_v3
from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.state import TaskPhase, transition from app.cad_agent.domain.state import TaskPhase, transition
from app.models.contracts import ChatMessage from app.models.contracts import ChatMessage
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
def _response_language(text: str) -> str:
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
latin = sum(1 for char in text if char.isascii() and char.isalpha())
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
_EVENT_LABELS = { _EVENT_LABELS = {
"image_observation": "参考图片观察", "requirements_ready": "需求分析",
"requirements_document_ready": "需求文档已冻结", "authoring_cdsl_ready": "完整 CDSL",
"completion_target_ready": "完成目标已冻结", "cdsl_compiled": "CDSL 编译",
"requirements_compiled": "需求合同已编译", "build_result": "CAD 构建",
"modeling_plan_ready": "建模计划已冻结", "repair_started": "CDSL 修复",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
"tool_call": "建模工具",
"candidate_result": "候选构建",
"candidate_review": "候选独立复核",
"final_review": "最终独立复核",
"task_terminal": "生成任务", "task_terminal": "生成任务",
"state_changed": "任务状态",
} }
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
lifecycle = str(payload.get("lifecycle") or "") lifecycle = str(payload.get("lifecycle") or "")
result = payload.get("result") if isinstance(payload.get("result"), dict) else {} status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status} return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
part_id = str(part.get("id") or "")
if part_id:
for index, existing in enumerate(parts):
if str(existing.get("id") or "") == part_id:
parts[index] = part
return
parts.append(part)
class AgentService: class AgentService:
"""Delivery boundary: no CAD state transitions or provider calls live here."""
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
self.settings = settings self.settings, self.store, self.library = settings, store, library
self.store = store # Conversation/attachment store, not v3 CAD state. self.cad: CadServices = compose_cad_services(settings)
self.library = library
self.v3: V3Services = compose_v3(settings)
self._autonomous_runs: dict[str, asyncio.Task[None]] = {} self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
async def resume_running_tasks(self) -> None: async def resume_running_tasks(self) -> None:
task_ids = self.v3.repository.running_task_ids()
try:
author_provider, author_model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
str(error),
retryable=False,
)
return
try:
author_capability, reviewer_capability = await asyncio.gather(
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
),
verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
),
)
except Exception as error:
# Startup recovery must never bypass a production capability gate.
# Connectivity failures are recoverable, but they may not leave a
# task falsely marked running without a worker.
await self._park_startup_tasks(
task_ids,
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
str(error),
retryable=True,
)
return
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
"Model protocol check is temporarily unavailable.",
retryable=True,
)
return
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
await self._park_startup_tasks(
task_ids,
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
"Selected author or reviewer did not pass the v3 conformance suite.",
retryable=False,
)
return
if not self.settings.resume_running_tasks_on_startup: if not self.settings.resume_running_tasks_on_startup:
return return
for task_id in task_ids:
if task_id in self._autonomous_runs:
continue
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
name=f"resume-autonomous-cad-v3-{task_id}",
)
async def _park_startup_tasks(
self,
task_ids: list[str],
error: ErrorCode,
message: str,
*,
retryable: bool,
) -> None:
"""Make a failed startup gate durable instead of leaving phantom runs."""
event_name = "waiting_retry" if retryable else "failed_model_capability"
for task_id in task_ids:
state = self.v3.repository.get_state(task_id)
if state is None or task_id in self._autonomous_runs:
continue
try:
next_state = transition(
state,
"waiting_retry" if retryable else "failed",
error=error,
)
except ValueError:
continue
if self.v3.repository.compare_and_swap(next_state, events=[{
"event": event_name,
"code": error.value,
"message": message[:1000],
"startup_recovery": True,
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
try: try:
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer): provider, model = self.settings.resolve_model(None, None)
# A resumed task has no active SSE client, but its durable except ValueError:
# state events must still leave the transactional outbox. return
await self.v3.outbox.dispatch_pending(task_id=task_id) author = ModelIdentity(provider.id, model.id)
for task_id in self.cad.repository.running_task_ids():
if task_id not in self._autonomous_runs:
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
try:
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
await self.cad.outbox.dispatch_pending(task_id=task_id)
finally: finally:
# Flush a final transition emitted immediately before the worker await self.cad.outbox.dispatch_pending(task_id=task_id)
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
await self.v3.outbox.dispatch_pending(task_id=task_id)
self._autonomous_runs.pop(task_id, None) self._autonomous_runs.pop(task_id, None)
async def cancel(self, task_id: str) -> dict[str, Any] | None: async def cancel(self, task_id: str) -> dict[str, Any] | None:
state = self.v3.repository.get_state(task_id) state = self.cad.repository.get_state(task_id)
if state is None: if state is None:
return None return None
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED) cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
if self.v3.repository.compare_and_swap(cancelled, events=[{ self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
"event": "task_cancelled", await self.cad.outbox.dispatch_pending(task_id=task_id)
"from_phase": state.phase.value,
"active_revision": state.active_revision,
"checkpoint_preserved": bool(state.active_revision),
}]):
await self.v3.outbox.dispatch_pending(task_id=task_id)
# Persist the terminal transition before interrupting the coroutine.
# A concurrent worker will fail its optimistic CAS instead of reviving
# the task after the caller has requested cancellation.
running = self._autonomous_runs.pop(task_id, None) running = self._autonomous_runs.pop(task_id, None)
if running and not running.done(): if running and not running.done():
running.cancel() running.cancel()
return self.v3.repository.get_task_projection(task_id) return self.cad.repository.get_task_projection(task_id)
async def resume_retry(self, task_id: str) -> dict[str, Any] | None: async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
"""Explicitly restart one durably parked infrastructure retry. state = self.cad.repository.get_state(task_id)
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
input, while this endpoint is only the controlled recovery route for
bounded provider/render/storage failures.
"""
state = self.v3.repository.get_state(task_id)
if state is None: if state is None:
return None return None
if state.phase.value != "WAITING_RETRY": if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint") raise ValueError("Only a retryable failed CAD task can be resumed")
running = self._autonomous_runs.get(task_id) if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done():
if running is not None and not running.done():
raise ValueError("The CAD task is already running") raise ValueError("The CAD task is already running")
author_provider, author_model = self.settings.resolve_model(None, None) provider, model = self.settings.resolve_model(None, None)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) if not self.cad.workflow.resume(task_id):
author_capability = await verify_model_capability( raise ValueError("The CAD task no longer has a retry checkpoint")
self.v3.repository, self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
self.v3.workflow.runtime, return self.cad.repository.get_task_projection(task_id)
self.v3.models,
provider_id=author_provider.id,
model_id=author_model.id,
role="author",
)
reviewer_capability = await verify_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=review_provider.id,
model_id=review_model.id,
role="reviewer",
)
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
if not self.v3.workflow.resume(task_id):
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
self._autonomous_runs[task_id] = asyncio.create_task(
self._consume_discarding(
task_id,
ModelIdentity(author_provider.id, author_model.id),
ModelIdentity(review_provider.id, review_model.id),
),
name=f"resume-autonomous-cad-v3-{task_id}",
)
return self.v3.repository.get_task_projection(task_id)
async def stream( async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
self,
messages: list[ChatMessage],
conversation_id: str | None,
selected_task_id: str | None,
provider_id: str | None = None,
model_id: str | None = None,
viewer_context: list[dict[str, Any]] | None = None,
) -> AsyncIterator[bytes]:
del viewer_context del viewer_context
latest_user = next((message for message in reversed(messages) if message.role == "user"), None) latest = next((item for item in reversed(messages) if item.role == "user"), None)
if latest_user is None or not text_from_message(latest_user): if latest is None or not text_from_message(latest):
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."}) yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
yield event("done", {}) yield event("done", {})
return return
request = text_from_message(latest_user) request = text_from_message(latest)
conversation = self.store.ensure_conversation(conversation_id) conversation = self.store.ensure_conversation(conversation_id)
selected = str(selected_task_id or conversation.get("current_task_id") or "") selected = str(selected_task_id or conversation.get("current_task_id") or "")
current = self.v3.repository.get_task_projection(selected) if selected else None current = self.cad.repository.get_task_projection(selected) if selected else None
resumed_task_id = "" resumed = False
if current and str(current.get("lifecycle") or "") == "waiting_for_user": if current and current.get("lifecycle") == "waiting_for_user":
state = self.v3.repository.get_state(selected) resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
if state is not None: if not resumed:
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state) yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
fields = [ yield event("done", {})
{"path": "/requirements/clarification", "message": str(question)} return
for question in terminal.get("questions") or () if current and current.get("lifecycle") == "running":
if str(question).strip() yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
]
fields.extend(
{"path": "/requirements", "message": str(issue)}
for issue in terminal.get("issues") or ()
if str(issue).strip()
)
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
yield event("cad_error", {
"stage": "request",
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
"fieldErrors": fields,
"taskId": selected,
"blockerType": terminal.get("blockerType"),
"userActionRequired": terminal.get("userActionRequired"),
})
yield event("done", {})
return
resumed_task_id = selected
if current and str(current.get("lifecycle") or "") == "running":
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
yield event("done", {}) yield event("done", {})
return return
# A terminal task is immutable; follow-up text creates a new task. task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" if not resumed:
if not resumed_task_id:
try: try:
source_blocks, image_inputs = self._task_inputs(conversation, request) source_blocks, image_inputs = self._task_inputs(conversation, request)
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
except ValueError as error: except ValueError as error:
yield event("cad_error", {"stage": "request", "message": str(error)}) yield event("cad_error", {"stage": "request", "message": str(error)})
yield event("done", {}) yield event("done", {})
return return
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."}) yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
try: try:
author_provider, author_model = self.settings.resolve_model(provider_id, model_id) provider, model = self.settings.resolve_model(provider_id, model_id)
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error: except ValueError as error:
state = self.v3.repository.get_state(task_id) state = self.cad.repository.get_state(task_id)
if state is not None: if state:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{ self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
"event": "model_configuration_invalid",
"message": str(error)[:1000],
"issues": [str(error)[:1000]],
}])
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
yield event("task_terminal", terminal)
yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("cad_error", {"stage": "configuration", "message": str(error)})
yield event("done", {}) yield event("done", {})
return return
author = ModelIdentity(provider.id, model.id)
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
parts: list[dict[str, Any]] = [] parts: list[dict[str, Any]] = []
sequence = 0
async def consume() -> None: async def consume() -> None:
nonlocal sequence
async def dispatch_state_events() -> None:
"""Publish state notifications only through the outbox.
The dispatcher preserves at-least-once semantics. The SSE
event ID derives from the durable outbox row, so reconnecting
clients can safely de-duplicate a delivery replay.
"""
nonlocal sequence
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
sequence += 1
event_name = str(outbox_event.get("event") or "state_changed")
payload = {
"taskId": task_id,
"eventId": f"outbox_{outbox_event['event_id']}",
"sequence": sequence,
"timestamp": now_iso(),
"outboxEvent": event_name,
"message": event_name.replace("_", " "),
}
await queue.put(("progress", _visible_progress("state_changed", payload)))
try: try:
capability_terminal = await self._ensure_task_capabilities( async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
task_id, decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
ModelIdentity(author_provider.id, author_model.id), parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
ModelIdentity(review_provider.id, review_model.id),
queue,
)
if capability_terminal is not None:
sequence += 1
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
await queue.put(("task_terminal", capability_terminal))
return
async for name, payload in self.v3.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
sequence += 1
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
"stage": "generation",
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
"tool": str(decorated.get("tool") or ""),
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
}})
await queue.put((name, decorated)) await queue.put((name, decorated))
await dispatch_state_events() await self.cad.outbox.dispatch_pending(task_id=task_id)
except Exception as error: if name == "task_terminal" and decorated.get("lifecycle") == "completed":
sequence += 1 projection = self.cad.repository.get_task_projection(task_id) or {}
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False} result = self._result_payload(task_id, projection)
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)}) if result is not None:
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}}) await queue.put(("cad_result", result))
await queue.put(("task_terminal", terminal))
finally: finally:
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id) self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
self._autonomous_runs.pop(task_id, None) self._autonomous_runs.pop(task_id, None)
await queue.put(None) await queue.put(None)
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}") self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
while True: while True:
try: item = await queue.get()
item = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
continue
if item is None: if item is None:
break break
name, payload = item name, payload = item
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": if name == "task_terminal" and payload.get("lifecycle") == "failed":
yield event("cad_error", { yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
"stage": "generation",
"message": str(payload.get("message") or "CAD autonomous generation failed."),
"tool": str(payload.get("tool") or ""),
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
})
yield event(name, payload) yield event(name, payload)
yield event("done", {}) yield event("done", {})
async def _ensure_task_capabilities( @staticmethod
self, def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
task_id: str, revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
author: ModelIdentity, revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
reviewer: ModelIdentity, revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None], if not isinstance(revision, dict):
) -> dict[str, Any] | None:
roles = (("author", author), ("reviewer", reviewer))
cached = {
role: cached_model_capability(
self.v3.repository,
self.v3.workflow.runtime,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in roles
}
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
missing = [(role, model) for role, model in roles if cached[role] is None]
if not missing:
return None return None
required = ("cdsl_path", "step_path", "glb_path", "report_path")
state = self.v3.repository.get_state(task_id) if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
if state is None: return None
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False} return {
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) "taskId": task_id,
if not self.v3.repository.compare_and_swap(waiting, events=[{ "revisionId": revision_id,
"event": "model_protocol_check_pending", "cdslPath": revision["cdsl_path"],
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "stepPath": revision["step_path"],
"message": "模型协议检查中,完成后将自动继续。", "glbPath": revision["glb_path"],
}]): "reportPath": revision["report_path"],
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False} "summary": str(revision.get("summary") or "CDSL CAD model"),
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"})) "referenceIds": list(revision.get("reference_ids") or []),
try: "engine": str(revision.get("engine") or "cdsl_only"),
results = await asyncio.gather(*( "lifecycle": str(projection.get("lifecycle") or "completed"),
verify_model_capability( }
self.v3.repository,
self.v3.workflow.runtime,
self.v3.models,
provider_id=model.provider_id,
model_id=model.model_id,
role=role,
)
for role, model in missing
))
except Exception as error:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
if unavailable:
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
if unsupported:
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
if not self.v3.workflow.resume(task_id):
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
return None
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
state = self.v3.repository.get_state(task_id)
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
self.v3.repository.compare_and_swap(failed, events=[{
"event": "model_protocol_unsupported",
"message": message,
"issues": [message],
}])
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""Freeze message paragraphs and attachment blocks before task creation."""
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()] blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
image_inputs: list[dict[str, str]] = [] image_inputs: list[dict[str, str]] = []
conversation_id = str(conversation.get("conversation_id") or "") conversation_id = str(conversation.get("conversation_id") or "")
@@ -509,51 +201,24 @@ class AgentService:
continue continue
if str(attachment.get("conversation_id") or "") != conversation_id: if str(attachment.get("conversation_id") or "") != conversation_id:
raise ValueError("Attachment does not belong to this conversation") raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "") attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
relative_path = str(attachment.get("path") or "") if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest):
expected_digest = str(attachment.get("sha256") or "")
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
raise ValueError("Attachment metadata is incomplete") raise ValueError("Attachment metadata is incomplete")
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path) binary = self.store.conversation_attachment_path(conversation_id, relative_path)
if not binary_path.is_file(): if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}") raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}")
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
kind = str(attachment.get("kind") or "") kind = str(attachment.get("kind") or "")
if kind == "document": if kind == "document":
extracted_path = str(attachment.get("extracted_path") or "") text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
if not extracted_path:
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
if not text_path.is_file(): if not text_path.is_file():
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}") raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
text = text_path.read_text(encoding="utf-8", errors="replace").strip() text = text_path.read_text(encoding="utf-8", errors="replace").strip()
elif kind == "image": elif kind == "image":
# The binary is integrity-checked above. Do not claim that a text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
# text-only author has interpreted visual content; the source image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
# block is still a stable, reviewable attachment reference.
text = (
f"Visual attachment {attachment.get('name') or attachment_id} "
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
"It is a visual reference and requires explicit visual verification."
)
image_inputs.append({
"path": str(binary_path),
"mime": str(attachment.get("mime") or "image/*"),
"sha256": expected_digest,
})
else: else:
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}") raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
if not text: if not text:
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}") raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
blocks.append({ blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}})
"text": text,
"attachment": {
"attachment_id": attachment_id,
"name": str(attachment.get("name") or attachment_id),
"kind": kind,
"mime": str(attachment.get("mime") or "application/octet-stream"),
"sha256": expected_digest,
},
})
return blocks, image_inputs return blocks, image_inputs
+17 -3
View File
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
except (OSError, json.JSONDecodeError) as error: except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict): if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
raise RuntimeError("The local engine schema has no v3 operation contract registry") raise RuntimeError("The local engine schema has no operation contract registry")
return schema return schema
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}") raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate the static Runtime CDSL contract without resolving topology."""
if not isinstance(cdsl, dict): if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object") raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1": if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
raise ValueError("Unsupported CDSL schema") raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine) _validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "") part_id = str(cdsl.get("part_id") or "")
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
sketches = cdsl.get("geometry", {}).get("sketches") sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list): if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires a feature list and a geometry.sketches array") raise ValueError("CDSL requires a feature list and a geometry.sketches array")
if cdsl.get("schema") == "cad.runtime.v1":
bodies = cdsl.get("bodies")
if not isinstance(bodies, list) or not bodies:
raise ValueError("Runtime CDSL requires server-assigned bodies")
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
sketch_ids = {str(sketch.get("id")) for sketch in sketches} sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine) semantic_contract = _engine_schema(engine)
try: try:
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError("Polygon profiles require vertices") raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS: elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}") raise ValueError(f"Unsupported CDSL profile: {profile_type}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
"""Validate static CDSL and the current executable topology semantics."""
validate_cdsl_shape(cdsl, engine)
try: try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl)) analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error: except Exception as error:
@@ -1,8 +1,8 @@
"""Deterministic, CPU-only CAD technical renders for visual review. """Deterministic, CPU-only CAD technical render bundles.
OpenCascade computes exact visible/hidden edges from the revision STEP file. OpenCascade computes exact visible/hidden edges from the revision STEP file.
Pillow rasterizes the resulting technical drawings. Neither stage needs a web Pillow rasterizes the resulting technical drawings. Neither stage needs a web
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
consistent on macOS, Linux, and Windows workers. consistent on macOS, Linux, and Windows workers.
""" """
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
HIDDEN_EDGE_RGB = (142, 157, 170) HIDDEN_EDGE_RGB = (142, 157, 170)
class ReviewRenderError(RuntimeError): class RenderBundleError(RuntimeError):
"""The fixed-view renderer was unavailable or produced incomplete evidence.""" """The fixed-view renderer was unavailable or produced incomplete evidence."""
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
"""Verify that the pure-Python/OCC renderer dependencies are importable.""" """Verify that the pure-Python/OCC renderer dependencies are importable."""
try: try:
_render_modules() _render_modules()
except ReviewRenderError as error: except RenderBundleError as error:
return False, str(error) return False, str(error)
return True, "" return True, ""
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
pillow_draw = importlib.import_module("PIL.ImageDraw") pillow_draw = importlib.import_module("PIL.ImageDraw")
import_step = importlib.import_module("build123d").import_step import_step = importlib.import_module("build123d").import_step
except (ImportError, AttributeError) as error: except (ImportError, AttributeError) as error:
raise ReviewRenderError( raise RenderBundleError(
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
) from error ) from error
return pillow_image, pillow_draw, import_step return pillow_image, pillow_draw, import_step
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
box = shape.bounding_box() box = shape.bounding_box()
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
if not all(math.isfinite(value) for value in bounds): if not all(math.isfinite(value) for value in bounds):
raise ReviewRenderError("STEP review source has invalid bounds") raise RenderBundleError("STEP render source has invalid bounds")
return bounds return bounds
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
points = [point for edge in edges for point in _edge_points(edge, 0.5)] points = [point for edge in edges for point in _edge_points(edge, 0.5)]
if not points: if not points:
raise ReviewRenderError("Hidden-line projection produced no drawable edges") raise RenderBundleError("Hidden-line projection produced no drawable edges")
xs, ys = zip(*points) xs, ys = zip(*points)
return min(xs), max(xs), min(ys), max(ys) return min(xs), max(xs), min(ys), max(ys)
@@ -242,7 +242,7 @@ def _render_view(
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
) )
except Exception as error: except Exception as error:
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
visible_edges, hidden_edges = list(visible), list(hidden) visible_edges, hidden_edges = list(visible), list(hidden)
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
rendered = _rasterize( rendered = _rasterize(
@@ -254,12 +254,12 @@ def _render_view(
intentional_crop=target_extent > 0, intentional_crop=target_extent > 0,
) )
if not rendered["diagnostics"]["valid"]: if not rendered["diagnostics"]["valid"]:
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
"""Create compact whole-model evidence for routine reviewer calls.""" """Create compact whole-model images for published CAD artifacts."""
pillow_image, pillow_draw, _ = _render_modules() pillow_image, pillow_draw, _ = _render_modules()
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
if not canonical: if not canonical:
@@ -283,27 +283,27 @@ def render_checkpoint(
*, *,
step_path: Path, step_path: Path,
output_dir: Path, output_dir: Path,
review_targets: list[dict[str, Any]] | None = None, detail_targets: list[dict[str, Any]] | None = None,
include_canonical: bool = True, include_canonical: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Render STEP geometry into stable canonical and bounded node-detail views.""" """Render STEP geometry into stable canonical and bounded node-detail views."""
del settings del settings
ready, detail = renderer_status() ready, detail = renderer_status()
if not ready: if not ready:
raise ReviewRenderError(detail) raise RenderBundleError(detail)
if not step_path.is_file(): if not step_path.is_file():
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
_, _, import_step = _render_modules() _, _, import_step = _render_modules()
try: try:
shape = import_step(str(step_path)) shape = import_step(str(step_path))
except Exception as error: except Exception as error:
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
bounds = _shape_bounds(shape) bounds = _shape_bounds(shape)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
jobs: list[tuple[str, dict[str, Any] | None]] = [] jobs: list[tuple[str, dict[str, Any] | None]] = []
if include_canonical: if include_canonical:
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
views = [ views = [
_render_view( _render_view(
shape=shape, shape=shape,
@@ -317,14 +317,14 @@ def render_checkpoint(
] ]
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
if include_canonical and canonical != set(CANONICAL_VIEWS): if include_canonical and canonical != set(CANONICAL_VIEWS):
raise ReviewRenderError("Python review renderer did not produce every canonical view") raise RenderBundleError("Python render bundle generator did not produce every canonical view")
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
manifest = { manifest = {
"schema_version": "cad.render-manifest.v2", "schema_version": "cad.render-manifest.v2",
"renderer": "python-occ-hlr-pillow", "renderer": "python-occ-hlr-pillow",
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, "render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
"contact_sheet_path": contact_sheet_path, "contact_sheet_path": contact_sheet_path,
"views": views, "views": views,
} }
@@ -343,22 +343,22 @@ def render_section(
"""Create an actual OpenCascade section drawing, not a clipped viewport. """Create an actual OpenCascade section drawing, not a clipped viewport.
It intentionally uses the same deterministic Pillow raster path as the It intentionally uses the same deterministic Pillow raster path as the
seven canonical review views. The output contains compact contour evidence seven canonical render views. The output contains compact contour evidence
suitable for a multimodal author without sending a STEP file or full B-rep. suitable for inspection without sending a STEP file or full B-rep.
""" """
del settings del settings
ready, detail = renderer_status() ready, detail = renderer_status()
if not ready: if not ready:
raise ReviewRenderError(detail) raise RenderBundleError(detail)
if not step_path.is_file(): if not step_path.is_file():
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}") raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
origin = _number_list(origin_mm, size=3) origin = _number_list(origin_mm, size=3)
direction = _number_list(normal, size=3) direction = _number_list(normal, size=3)
if origin is None or direction is None: if origin is None or direction is None:
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers") raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
length = math.sqrt(sum(value * value for value in direction)) length = math.sqrt(sum(value * value for value in direction))
if length <= 1e-9: if length <= 1e-9:
raise ReviewRenderError("Section normal must not be zero") raise RenderBundleError("Section normal must not be zero")
normal_unit = [value / length for value in direction] normal_unit = [value / length for value in direction]
_, _, import_step = _render_modules() _, _, import_step = _render_modules()
try: try:
@@ -372,9 +372,9 @@ def render_section(
section = b3d.section(shape, section_by=plane) section = b3d.section(shape, section_by=plane)
edges = list(section.edges()) edges = list(section.edges())
except Exception as error: except Exception as error:
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
if not edges: if not edges:
raise ReviewRenderError("Section plane does not intersect the model") raise RenderBundleError("Section plane does not intersect the model")
# Choose a deterministic right-handed in-plane frame. Projecting exact # Choose a deterministic right-handed in-plane frame. Projecting exact
# OCC section edges into this frame preserves holes and internal contours. # OCC section edges into this frame preserves holes and internal contours.
@@ -406,7 +406,7 @@ def render_section(
if len(line) >= 2: if len(line) >= 2:
projected.append(line) projected.append(line)
if not projected: if not projected:
raise ReviewRenderError("Section operation produced no drawable contours") raise RenderBundleError("Section operation produced no drawable contours")
xs = [point[0] for line in projected for point in line] xs = [point[0] for line in projected for point in line]
ys = [point[1] for line in projected for point in line] ys = [point[1] for line in projected for point in line]
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys) minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Conversation and attachment storage for the v3 delivery boundary. """Conversation and attachment storage for the CAD delivery boundary.
CAD task state deliberately does not live here. Protocol v3 owns mutable task CAD task state deliberately does not live here. The Authoring protocol owns mutable task
state in ``SqliteTaskRepository`` and immutable task artifacts in state in ``SqliteTaskRepository`` and immutable task artifacts in
``FileArtifactStore``. ``FileArtifactStore``.
""" """
-41
View File
@@ -65,15 +65,6 @@ class Settings:
llm_timeout_s: float llm_timeout_s: float
default_provider_id: str default_provider_id: str
providers: tuple[ProviderConfig, ...] providers: tuple[ProviderConfig, ...]
review_provider_id: str = ""
review_model_id: str = ""
agent_tool_calls_per_cycle: int = 12
agent_consecutive_no_progress_limit: int = 6
agent_format_error_repeat_limit: int = 3
agent_context_char_limit: int = 14000
agent_author_guidance_enabled: bool = True
agent_author_guidance_max_chars: int = 3600
agent_render_cache: bool = True
autonomous_generation: bool = True autonomous_generation: bool = True
resume_running_tasks_on_startup: bool = True resume_running_tasks_on_startup: bool = True
@@ -102,29 +93,6 @@ class Settings:
raise ValueError("The selected model is not enabled for this provider") raise ValueError("The selected model is not enabled for this provider")
return provider, model return provider, model
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
"""Return the independently configured visual reviewer, never an author fallback."""
provider_id = self.review_provider_id
if not provider_id:
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The configured visual review provider is unavailable")
model_id = self.review_model_id or ""
if not model_id:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
model = provider.model(model_id)
if model is None or not model.vision:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
return provider, model
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
"""Require the candidate judge to be a separately configured model."""
provider, model = self.resolve_review_model()
if provider.id == author_provider.id and model.id == author_model.id:
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
return provider, model
def _reasoning_effort(value: str) -> str: def _reasoning_effort(value: str) -> str:
effort = value.strip().lower() effort = value.strip().lower()
@@ -207,15 +175,6 @@ def get_settings() -> Settings:
llm_timeout_s=llm_timeout_s, llm_timeout_s=llm_timeout_s,
default_provider_id=default_provider_id, default_provider_id=default_provider_id,
providers=providers, providers=providers,
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True),
agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
autonomous_generation=True, autonomous_generation=True,
# Production instances recover durable runs by default. Test workers # Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks # can disable this before startup to guarantee they touch only tasks
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+524 -23
View File
@@ -5,11 +5,12 @@
"description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.", "description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.",
"type": "object", "type": "object",
"properties": { "properties": {
"schema": {"const": "cad.cdsl.llm.v1"}, "schema": {"enum": ["cad.cdsl.llm.v1", "cad.runtime.v1"]},
"schema_version": {"type": "string"}, "schema_version": {"type": "string"},
"kind": {"type": "string", "minLength": 1}, "kind": {"type": "string", "minLength": 1},
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
"meta": {"type": "object"}, "meta": {"type": "object"},
"bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}},
"geometry": { "geometry": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -26,6 +27,15 @@
"number": {"type": "number"}, "number": {"type": "number"},
"positive": {"type": "number", "exclusiveMinimum": 0}, "positive": {"type": "number", "exclusiveMinimum": 0},
"positiveInteger": {"type": "integer", "minimum": 1}, "positiveInteger": {"type": "integer", "minimum": 1},
"runtimeBody": {
"type": "object",
"properties": {
"id": {"type": "string", "pattern": "^body_[0-9]{3}$"},
"name": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
},
"required": ["id", "name"],
"additionalProperties": false
},
"point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2}, "point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2},
"point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3}, "point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3},
"circleItem": { "circleItem": {
@@ -85,12 +95,23 @@
"properties": { "properties": {
"type": {"type": "string", "minLength": 1}, "type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"}, "solidworks_code": {"type": "integer"},
"reference": {"$ref": "#/$defs/selectorRef"}, "reference": {"oneOf": [{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/sourceVertexDatumRef"}]},
"offset_mm": {"type": "number", "minimum": 0} "offset_mm": {"type": "number", "minimum": 0}
}, },
"required": ["type", "solidworks_code"], "required": ["type", "solidworks_code"],
"additionalProperties": false "additionalProperties": false
}, },
"sourceVertexDatumRef": {
"type": "object",
"properties": {
"kind": {"const": "source_vertex"},
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"point_mm": {"$ref": "#/$defs/point3"}
},
"required": ["kind", "source_sketch_id", "source_entity_id", "point_mm"],
"additionalProperties": false
},
"selectorOrUnresolved": { "selectorOrUnresolved": {
"oneOf": [ "oneOf": [
{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/selectorRef"},
@@ -130,6 +151,14 @@
"maxItems": 16, "maxItems": 16,
"uniqueItems": true, "uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"} "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"initial_output_roles": {"type": "boolean"},
"cap_output_profile_sources": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
} }
}, },
"required": ["profile_sketch_ids"], "required": ["profile_sketch_ids"],
@@ -150,20 +179,92 @@
"additionalProperties": false "additionalProperties": false
}, },
"sweepPath": { "sweepPath": {
"type": "object", "oneOf": [
"properties": { {
"workplane": {"$ref": "#/$defs/workplane"}, "type": "object",
"segment": {"$ref": "#/$defs/analyticSegment"} "properties": {
}, "workplane": {"$ref": "#/$defs/workplane"},
"required": ["workplane", "segment"], "segment": {"$ref": "#/$defs/analyticSegment"},
"additionalProperties": false "segments": {
"type": "array",
"minItems": 2,
"items": {"$ref": "#/$defs/analyticSegment"}
}
},
"required": ["workplane"],
"oneOf": [
{"required": ["segment"], "not": {"required": ["segments"]}},
{"required": ["segments"], "not": {"required": ["segment"]}}
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"segments": {
"type": "array",
"minItems": 2,
"items": {"$ref": "#/$defs/spatialSweepSegment"}
}
},
"required": ["segments"],
"additionalProperties": false
}
]
}, },
"sweepParams": { "sweepParams": {
"type": "object", "type": "object",
"properties": { "properties": {
"path": {"$ref": "#/$defs/sweepPath"}, "path": {"$ref": "#/$defs/sweepPath"},
"is_frenet": {"type": "boolean"}, "is_frenet": {"type": "boolean"},
"result_mode": {"enum": ["fuse", "new_body"]} "result_mode": {"enum": ["fuse", "new_body"]},
"initial_output_roles": {"type": "boolean"},
"cap_output_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entity", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
},
"swept_face_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entities": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 160},
"minItems": 1,
"uniqueItems": true
},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
},
"swept_edge_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entities": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 160},
"minItems": 2,
"uniqueItems": true
},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
}
}, },
"required": ["path"], "required": ["path"],
"additionalProperties": false "additionalProperties": false
@@ -395,14 +496,17 @@
"operation": {"enum": ["union", "subtract", "intersect"]}, "operation": {"enum": ["union", "subtract", "intersect"]},
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, "target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"target_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, "tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"keep_tools": {"type": "boolean"} "tool_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"keep_tools": {"type": "boolean"},
"targetless_body_set": {"type": "boolean"}
}, },
"required": ["operation"], "required": ["operation"],
"allOf": [ "allOf": [
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]}, {"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}, {"required": ["target_transform_copy_refs"]}]},
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]} {"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}, {"required": ["tool_transform_copy_refs"]}]}
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -443,12 +547,22 @@
"required": ["transform_feature_id", "source_feature_id"], "required": ["transform_feature_id", "source_feature_id"],
"additionalProperties": false "additionalProperties": false
}, },
"transformSourceMemberAlias": {
"type": "object",
"properties": {
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"active_member_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"required": ["source_feature_id", "active_member_feature_id"],
"additionalProperties": false
},
"transformBodiesParams": { "transformBodiesParams": {
"type": "object", "type": "object",
"properties": { "properties": {
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}}, "pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}}, "transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"source_member_aliases": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformSourceMemberAlias"}},
"transform": {"$ref": "#/$defs/bodyTransform"}, "transform": {"$ref": "#/$defs/bodyTransform"},
"make_copy": {"type": "boolean"} "make_copy": {"type": "boolean"}
}, },
@@ -523,6 +637,22 @@
"required": ["axis"], "required": ["axis"],
"additionalProperties": false "additionalProperties": false
}, },
"referencePointParams": {
"type": "object",
"properties": {"point_mm": {"$ref": "#/$defs/point3"}},
"required": ["point_mm"],
"additionalProperties": false
},
"assignVariableParams": {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 256},
"value": {"type": "number"},
"value_kind": {"enum": ["any", "length"]}
},
"required": ["name", "value", "value_kind"],
"additionalProperties": false
},
"holeWizardParams": { "holeWizardParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -532,6 +662,7 @@
"end_condition": {"$ref": "#/$defs/endCondition"}, "end_condition": {"$ref": "#/$defs/endCondition"},
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}}, "positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
"host_face": {"$ref": "#/$defs/hostFace"}, "host_face": {"$ref": "#/$defs/hostFace"},
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"thread": {"type": "object"}, "thread": {"type": "object"},
"countersink": {"type": "object"}, "countersink": {"type": "object"},
"counterbore": {"type": "object"} "counterbore": {"type": "object"}
@@ -541,7 +672,7 @@
}, },
"featureOutputRole": { "featureOutputRole": {
"enum": [ "enum": [
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end",
"shell.offset_face", "shell.closing_descendant", "shell.body_face" "shell.offset_face", "shell.closing_descendant", "shell.body_face"
] ]
}, },
@@ -554,6 +685,300 @@
"required": ["owner_feature_id", "output_role"], "required": ["owner_feature_id", "output_role"],
"additionalProperties": false "additionalProperties": false
}, },
"selectorIntentSourceQuery": {
"type": "object",
"properties": {
"ast": {},
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
"standard_library": {"type": "string", "minLength": 1},
"standard_library_version": {"type": "string", "minLength": 1},
"standard_library_imports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 1},
"version": {"type": "string", "minLength": 1}
},
"required": ["path"],
"additionalProperties": false
}
}
},
"required": ["ast"],
"additionalProperties": false
},
"selectorIntentQueryExprNode": {
"oneOf": [
{
"type": "object",
"properties": {
"node": {"const": "set"},
"operator": {"enum": ["union", "intersection", "subtraction"]},
"operands": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "operator", "operands"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "filter"},
"filter": {"enum": ["adjacent", "owner_body", "body_type", "construction"]},
"input": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "filter", "input", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "topology_query"},
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"topology_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "owner", "topology_type", "entity_type", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "created_by"},
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "owner", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "source_entity"},
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "sketch", "entity_type", "entity", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "sketch_region"},
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "sketch", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "opaque_call"},
"name": {"type": "string", "minLength": 1},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "name", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "list"},
"items": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "items"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "map"},
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
},
"required": ["key", "value"],
"additionalProperties": false
}
}
},
"required": ["node", "entries"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "literal"},
"value": {}
},
"required": ["node", "value"],
"additionalProperties": false
}
]
},
"selectorIntentQueryExpr": {
"type": "object",
"properties": {
"version": {"const": "1.0"},
"root": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
},
"required": ["version", "root"],
"additionalProperties": false
},
"selectorIntent": {
"type": "object",
"properties": {
"version": {"const": "1.0"},
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
"query_expr": {"$ref": "#/$defs/selectorIntentQueryExpr"},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"source_entities": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"intersection_sources": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {
"type": "object",
"properties": {
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"output_role": {"enum": ["extrude.start", "extrude.end"]},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"required": ["query_family", "owner_feature_id"],
"additionalProperties": false
}
},
"blend_sources": {
"type": "object",
"properties": {
"edge": {
"type": "object",
"properties": {
"query_family": {"const": "CAP_EDGE"},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
},
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
"additionalProperties": false
},
"face": {
"type": "object",
"properties": {
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"output_role": {"enum": ["extrude.start", "extrude.end"]},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"required": ["query_family", "owner_feature_id"],
"additionalProperties": false
}
},
"required": ["edge", "face"],
"additionalProperties": false
},
"blend_face_source": {
"type": "object",
"properties": {
"query_family": {"const": "CAP_EDGE"},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
},
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
"additionalProperties": false
},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"consumer_contract": {"enum": ["direct_prism_cap_face_workplane", "primary_add_shell_union_continuation", "primary_add_up_to_surface_union_continuation", "primary_add_dressup_union_continuation", "symmetric_direct_prism_two_sided_up_to_surface_cap_pair", "symmetric_direct_prism_shell_swept_face_up_to_surface_pair", "immediate_retained_source_prism_swept_face_up_to_surface", "shell_retained_direct_prism_cap_offset_face_profile", "direct_prism_shell_offset_edge_tdd", "direct_prism_shell_offset_edge_vertex"]},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
"body_member_contract": {"enum": ["direct_new_body"]},
"query_set_contract": {"enum": ["proven_operand_union", "proven_operand_intersection", "proven_operand_subtraction"]},
"owner_body_contract": {"enum": ["exact_input_owner"]},
"copy_contract": {"enum": ["primary_cut_cap_edge", "primary_cut_cap_face_workplane", "primary_cut_swept_face_workplane"]},
"set_kind": {"enum": ["face", "edge"]},
"body_scope": {"enum": ["active_member"]},
"empty_policy": {"enum": ["reject"]},
"multiple_policy": {"enum": ["all", "one"]},
"derivation_policy": {
"type": "object",
"properties": {
"allowed": {"type": "array", "minItems": 1, "items": {"enum": ["continuation", "fragment", "merge", "intersection", "boundary", "replacement"]}},
"multiplicity": {"enum": ["one", "all_fragments", "source_qualified", "none"]}
},
"required": ["allowed", "multiplicity"],
"additionalProperties": false
},
"evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]},
"disambiguation": {"type": "object"}
},
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
"additionalProperties": false
},
"selectorRef": { "selectorRef": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -568,12 +993,16 @@
"snapshot_id": {"type": "string", "minLength": 1}, "snapshot_id": {"type": "string", "minLength": 1},
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"match_mode": {"enum": ["unique", "all"]}, "match_mode": {"enum": ["unique", "all"]},
"query_input": {"$ref": "#/$defs/selectorRef"},
"query_operands": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}}, "matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"selector_intent_version": {"const": "1.0"},
"selector_intent": {"$ref": "#/$defs/selectorIntent"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1} "confidence": {"type": "number", "minimum": 0, "maximum": 1}
}, },
"required": ["kind", "source", "confidence"], "required": ["kind", "source", "confidence"],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}], "anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
"additionalProperties": false "additionalProperties": false
}, },
"analyticSegment": { "analyticSegment": {
@@ -594,6 +1023,7 @@
"clockwise": {"type": "boolean"}, "clockwise": {"type": "boolean"},
"start_tangent": {"$ref": "#/$defs/point2"}, "start_tangent": {"$ref": "#/$defs/point2"},
"end_tangent": {"$ref": "#/$defs/point2"} "end_tangent": {"$ref": "#/$defs/point2"}
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
}, },
"required": ["type"], "required": ["type"],
"allOf": [ "allOf": [
@@ -606,6 +1036,33 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"spatialSweepSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "bspline"]},
"start_mm": {"$ref": "#/$defs/point3"},
"end_mm": {"$ref": "#/$defs/point3"},
"center_mm": {"$ref": "#/$defs/point3"},
"normal": {"$ref": "#/$defs/point3"},
"radius_mm": {"$ref": "#/$defs/positive"},
"points_mm": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point3"}},
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"clockwise": {"type": "boolean"},
"start_tangent_mm": {"$ref": "#/$defs/point3"},
"end_tangent_mm": {"$ref": "#/$defs/point3"},
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
},
"required": ["type", "source_sketch_id", "source_entity_id"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start_mm", "end_mm"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start_mm", "end_mm", "center_mm", "normal", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["points_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}, "points_mm": {"maxItems": 2}}}, "then": {"required": ["start_tangent_mm", "end_tangent_mm", "parameters"], "properties": {"periodic": {"const": false}}}}
],
"additionalProperties": false
},
"constructionBsplineSegment": { "constructionBsplineSegment": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -629,7 +1086,8 @@
"type": "object", "type": "object",
"properties": { "properties": {
"role": {"enum": ["outer", "inner", "open", "unknown"]}, "role": {"enum": ["outer", "inner", "open", "unknown"]},
"closed": {"type": "boolean"}, "closed": {"type": "boolean"},
"surface_wire": {"type": "boolean"},
"segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}} "segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}}
}, },
"required": ["role", "closed", "segments"], "required": ["role", "closed", "segments"],
@@ -658,10 +1116,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}, "anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"external_anchor_id": {"type": "string", "minLength": 1, "maxLength": 160},
"side": {"enum": [-1, 1]}, "side": {"enum": [-1, 1]},
"intersection_index": {"type": "integer", "minimum": 0} "intersection_index": {"type": "integer", "minimum": 0}
}, },
"required": ["anchor_entity_id", "side"], "required": ["side"],
"oneOf": [
{"required": ["anchor_entity_id"]},
{"required": ["external_anchor_id"]}
],
"additionalProperties": false
},
"imprintExternalAnchor": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 160},
"selector": {"$ref": "#/$defs/selectorRef"}
},
"required": ["id", "selector"],
"additionalProperties": false "additionalProperties": false
}, },
"imprintSelection": { "imprintSelection": {
@@ -678,13 +1150,37 @@
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "planar_imprint"}, "type": {"const": "planar_imprint"},
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}}, "source_entities": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}} "selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}},
"external_anchors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintExternalAnchor"}}
}, },
"required": ["type", "source_entities", "selections"], "required": ["type", "source_entities", "selections"],
"allOf": [
{
"if": {"not": {"required": ["external_anchors"]}},
"then": {"properties": {"source_entities": {"minItems": 2}}}
}
],
"additionalProperties": false "additionalProperties": false
}, },
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "multiSourceRegionsProfile": {
"type": "object",
"properties": {
"type": {"const": "multi_source_regions"},
"source_sketch_ids": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1, "maxLength": 160}, "uniqueItems": true},
"profiles": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/directProfile"}}
},
"required": ["type", "source_sketch_ids", "profiles"],
"additionalProperties": false
},
"directProfile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"}
]
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard"]},
"feature": { "feature": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -710,7 +1206,9 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
@@ -737,21 +1235,24 @@
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_point"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePointParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "assign_variable"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/assignVariableParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
] ]
}, },
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]}, "profile_type": {"enum": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"]},
"profile": { "profile": {
"oneOf": [ "oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"}, {"$ref": "#/$defs/analyticProfile"},
{"$ref": "#/$defs/multiSourceRegionsProfile"},
{"$ref": "#/$defs/planarImprintProfile"} {"$ref": "#/$defs/planarImprintProfile"}
] ]
}, },
"sketch": { "sketch": {
"type": "object", "type": "object",
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, "properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"required": ["id", "workplane", "profile"], "required": ["id", "workplane", "profile"],
"additionalProperties": false "additionalProperties": false
} }
@@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
params = node.params params = node.params
target_ids = _member_sources( target_ids = _member_sources(
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
) )
tool_ids = _member_sources( tool_ids = _member_sources(
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_refs",
) )
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
@@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
} }
members[node.feature_id] = result members[node.feature_id] = result
if bool(params.get("keep_tools")): if bool(params.get("keep_tools")):
# In a targetless FeatureScript body-set operation every selected
# member is semantically a tool. Lowering chooses the first source
# member only to satisfy CDSL's binary executor shape, so retain that
# left operand too when the explicit targetless contract requests it.
if bool(params.get("targetless_body_set")):
members.update(targets)
members.update(tools) members.update(tools)
session.register_body( session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
+472 -61
View File
@@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead.
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import math import math
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
from ..extents import _extent_vectors_from_normal, _normal_from_sketch from ..extents import _extent_vectors_from_normal, _normal_from_sketch
from ..runtime_base import ExtentVector, FeatureExecutionError from ..runtime_base import ExtentVector, FeatureExecutionError
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord
if TYPE_CHECKING: # pragma: no cover - import for type checkers only if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession from ..session import ExecutionSession
@@ -57,20 +58,76 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
) )
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]: def _cut_explicit_body_members(
session: "ExecutionSession", tool: Any,
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
"""Apply a cut to each independently owned body without erasing ownership. """Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely. and drops only members that the tool removes completely. Each non-empty
member result retains its own OCC builder history; callers may compose
those disjoint exact relations into one operation-wide delta.
""" """
members: dict[str, Any] = {} members: dict[str, Any] = {}
deltas: list[TopologyDelta] = []
for feature_id, body in session.body_members.items(): for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool) result, delta = session.adapter.cut_with_topology_delta(body, tool)
if abs(float(result.volume)) > 1e-12: if delta is not None:
deltas.append(delta)
if result is not None and abs(float(result.volume)) > 1e-12:
members[feature_id] = result members[feature_id] = result
return members return members, tuple(deltas)
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
"""Combine exact independent member-cut histories without inventing links."""
if not deltas or any(delta.history_status != "proven" for delta in deltas):
return None
return TopologyDelta(
operation="subtract",
relations=tuple(relation for delta in deltas for relation in delta.relations),
section_values=tuple(value for delta in deltas for value in delta.section_values),
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
history_reason="per_member_exact_cut_history",
)
def _can_register_primary_cut_tool_history(
session: "ExecutionSession",
tool: Any,
topology_delta: TopologyDelta | None,
topology_anchors: list[TopologyRecord] | None,
) -> bool:
"""Return whether a primary REMOVE can retain a transient tool snapshot.
The implicit CADFS primary boolean normally has no independently active
tool body. It may retain its direct-prism input snapshot whenever the
tool, profile anchors, and prism builder history are singular and exact.
The target may contain several independent members: that changes the cut
result cardinality but not the identity of the one transient tool input.
This gate does not make any multi-member selector executable; a later
resolver must still prove its complete operation-wide relation and active
member. The cut builder can independently prove a target-side continuation
even when a trimmed or fallback tool has no direct-prism history.
"""
if (
session.body is None
or topology_delta is None
or topology_delta.operation != "extrude"
or topology_delta.history_status != "proven"
or not topology_delta.relations
or not topology_anchors
or len(session.adapter.body_solids(tool)) != 1
):
return False
return all(
anchor.kind in {"edge", "vertex"}
and (anchor.source_entity is not None or anchor.source_entities)
for anchor in topology_anchors
)
def _extruded_tool( def _extruded_tool(
@@ -78,8 +135,11 @@ def _extruded_tool(
faces: list[Any], faces: list[Any],
profile_normal: Vector3, profile_normal: Vector3,
session: "ExecutionSession", session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
use_operation_wide_prism_history: bool = False,
) -> tuple[Any, TopologyDelta | None]: ) -> tuple[Any, TopologyDelta | None]:
"""Build one extrude tool, retaining caps only from one exact builder result.""" """Build an extrude tool, retaining complete direct builder history."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session) extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft") draft = node.params.get("draft")
taper_deg = 0.0 taper_deg = 0.0
@@ -87,20 +147,59 @@ def _extruded_tool(
taper_deg = float(draft["angle_deg"]) taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]): if not bool(draft["pull_direction"]):
taper_deg = -taper_deg taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None if (
use_operation_wide_prism_history
and draft is None
and len(faces) > 1
and len(extents) == 1
and extents[0].trim_to is None
):
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
if composed is not None:
return composed
topology_deltas: list[tuple[int, TopologyDelta]] = []
solids: list[Any] = [] solids: list[Any] = []
exact_two_sided_prism = (
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
and draft is None
and len(faces) == 1
and len(extents) == 2
and all(extent.trim_to is None for extent in extents)
)
for face in faces: for face in faces:
for extent in extents: for extent_index, extent in enumerate(extents):
if draft is not None: if draft is not None:
if len(faces) == 1 and len(extents) == 1: if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg, face, extent.vector, taper_deg,
) )
if topology_delta is not None:
topology_deltas.append((extent_index, topology_delta))
solids.append(solid) solids.append(solid)
else: else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and (
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) len(faces) == 1 or record_multiface_prism_history
):
# Each independently constructed profile face has its own OCC
# prism history only when the adapter retained direct source
# anchors for every participating profile path. Other complex
# multi-face profiles keep the established general-extrude
# path; forcing them through MakePrism can make a previously
# executable profile invalid without adding usable evidence.
try:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
except ValueError:
# Keep the established executable profile result when a
# selected IMPRINT region is valid as a face but cannot be
# a standalone valid prism. Its later fuse may still be
# valid. There is no complete builder witness in this
# case, so the entire multi-region topology delta is
# withheld below instead of mixing proven and guessed
# source anchors.
solid = session.adapter.extrude(face, extent.vector)
else:
topology_deltas.append((extent_index, topology_delta))
solids.append(solid) solids.append(solid)
elif extent.trim_to is None: elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector)) solids.append(session.adapter.extrude(face, extent.vector))
@@ -111,7 +210,24 @@ def _extruded_tool(
tool = session.adapter.fuse(tool, solid) tool = session.adapter.fuse(tool, solid)
if tool is None: if tool is None:
raise ValueError("extrude produced no solid") raise ValueError("extrude produced no solid")
return tool, topology_delta if len(topology_deltas) != len(solids):
return tool, None
relations: list[TopologyDeltaRelation] = []
for extent_index, topology_delta in topology_deltas:
for relation in topology_delta.relations:
# Both prism builders start on the source plane. Its two source
# caps are internal to the fused two-sided result. The reverse
# extent's far ``LastShape`` is the FeatureScript start cap; map
# that exact builder handle before the registry checks final
# membership. No source-plane or geometry-derived relation is
# promoted to a CAP role.
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
relation = replace(relation, output_role="extrude.start")
relations.append(relation)
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relations),
)
def _apply_primary_tool( def _apply_primary_tool(
@@ -121,43 +237,128 @@ def _apply_primary_tool(
*, *,
cutting: bool, cutting: bool,
topology_delta: TopologyDelta | None = None, topology_delta: TopologyDelta | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> FeatureResult: ) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" """Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
topology_predecessors: list[TopologyRecord] | None = None
if cutting: if cutting:
if session.body is None: if session.body is None:
raise ValueError("cut feature has no body") raise ValueError("cut feature has no body")
members = _cut_explicit_body_members(session, tool) members, member_cut_deltas = _cut_explicit_body_members(session, tool)
if not members: if not members:
session.clear_body() session.clear_body()
return session.result(node) return session.result(node)
body = session.adapter.cut(session.body, tool) tool_delta = topology_delta
topology_delta = None tool_anchors = list(topology_anchors or ())
retain_transient_tool = _can_register_primary_cut_tool_history(
session, tool, tool_delta, topology_anchors,
)
if len(session.body_members) == 1:
member_id = next(iter(session.body_members))
body = members[member_id]
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
# The target-side boolean history is independent of the source
# tool's construction history. A trimmed tool cannot support a
# source-qualified section query, but its exact BRepAlgoAPI_Cut
# Modified/Preserved facts can still prove a unique continuation
# of the active target face or edge.
topology_anchors = None
if cut_delta is not None:
if retain_transient_tool:
try:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=tool_delta,
topology_anchors=tool_anchors,
)
except ValueError:
# Retain cut history as partial diagnostic evidence. The
# absent transient source records prevent it from proving
# a source-qualified section edge.
topology_predecessors = None
topology_delta = cut_delta
else:
topology_delta = None
members = {member_id: body}
else:
if retain_transient_tool:
try:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=tool_delta,
topology_anchors=tool_anchors,
)
except ValueError:
# A transient tool is optional evidence. Keep the exact
# per-member target history when its tool snapshot cannot
# be registered completely.
topology_predecessors = None
body = session.adapter.combine(None, next(iter(members.values())))
for member in list(members.values())[1:]:
body = session.adapter.combine(body, member)
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
topology_anchors = None
elif node.params.get("result_mode") == "new_body": elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool) body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool} members = {**session.body_members, node.feature_id: tool}
else: else:
body = session.adapter.fuse(session.body, tool) # An ADD can replace both the prior active solid and its direct prism
# tool. Retain a selector relation only when both snapshots are
# singular and OCC supplies the exact union history. The direct-prism
# snapshot remains transient: its source anchors cannot be selected
# until the union proves a complete successor in the active result.
can_trace_add = (
session.body is not None
and topology_delta is not None
and topology_delta.operation == "extrude"
and topology_delta.history_status == "proven"
and bool(topology_delta.relations)
and bool(topology_anchors)
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
)
if can_trace_add:
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
if fuse_delta is not None:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=topology_delta,
topology_anchors=list(topology_anchors or ()),
)
body = fused
topology_delta = fuse_delta
topology_anchors = None
else:
body = fused
topology_delta = None
topology_anchors = None
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body} members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
session.register_body( session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
) )
return session.result(node) return session.result(node)
def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult: def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult:
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
# 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景),
# 否则按 sketch_id 从会话草图表中取原始草图。 # 否则按 sketch_id 从会话草图表中取原始草图。
selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
if selected_sketch is None: if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch") raise ValueError("primary feature has no resolved sketch")
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
faces = session.adapter.faces_for_sketch(selected_sketch) support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
)
if not faces: if not faces:
raise ValueError("sketch does not create a closed profile region") raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole": if node.atomic_id == "extrude_add_blind_with_hole":
@@ -169,6 +370,17 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
raise ValueError("profile hole extrusion requires exactly one outer sketch region") raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None topology_delta: TopologyDelta | None = None
topology_anchors: list[TopologyRecord] = []
profile = selected_sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
)
planar_imprint_profile = profile.get("type") == "planar_imprint"
# 3. 按特征类型生成子实体: # 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"): if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -177,7 +389,35 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool( tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session, node, faces, _normal_from_sketch(selected_sketch), session,
# A multi-region prism gets per-region builder history only when
# every profile region has at least one exact source boundary.
# This covers direct circles and the adapter's bounded IMPRINT
# splitter path, without turning arbitrary multi-face profiles
# into a different construction algorithm.
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
and len(source_anchor_specs) >= len(faces),
use_operation_wide_prism_history=planar_imprint_profile
and len(source_anchor_specs) >= len(faces),
) )
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
value = spec.get("value")
if kind not in {"edge", "vertex"} or value is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if source_entity is None and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=value,
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
else: else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session) axis = _revolve_axis(node, session)
@@ -193,12 +433,47 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
if bool(node.params.get("reverse")): if bool(node.params.get("reverse")):
angle = -angle angle = -angle
tool = None tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces): can_record_revolve_history = (
tool = session.adapter.fuse(tool, solid) node.atomic_id == "revolve_add"
and node.params.get("result_mode") == "new_body"
and len(faces) == 1
)
for face in faces:
if can_record_revolve_history:
try:
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
except ValueError:
# Retain the established executable revolve when OCC
# cannot expose a complete builder-history witness.
solid = session.adapter.revolve(face, angle, axis)
topology_delta = None
else:
solid = session.adapter.revolve(face, angle, axis)
tool = solid if tool is None else session.adapter.fuse(tool, solid)
if tool is None: if tool is None:
raise ValueError("revolve produced no solid") raise ValueError("revolve produced no solid")
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
value = spec.get("value")
if kind not in {"edge", "vertex"} or value is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if source_entity is None and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=value,
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
return _apply_primary_tool( return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
) )
@@ -248,10 +523,14 @@ def _pattern_instance_sources(
return resolved return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: def _transform_copy_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str = "transform_copy_refs",
) -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms.""" """Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = [] resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or (): for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict): if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object") raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "") transform_id = str(reference.get("transform_feature_id") or "")
@@ -285,12 +564,13 @@ def _member_sources(
*, *,
pattern_instance_parameter: str | None = None, pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False, allow_transform_copies: bool = False,
transform_copy_parameter: str = "transform_copy_refs",
) -> list[str]: ) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []] source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None: if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies: if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session)) source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter))
if not source_ids: if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}") raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
@@ -304,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
path = node.params.get("path") or {} path = node.params.get("path") or {}
if not isinstance(path, dict): if not isinstance(path, dict):
raise ValueError("sweep path must be an object") raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {}) spatial = path.get("workplane") is None
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {} segment = path.get("segment") or {}
if not isinstance(segment, dict): segments = path.get("segments")
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
def point(value: list[float]) -> Vector3: def local_point(value: Any) -> Vector3:
if plane is None:
raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path requires two-dimensional points")
return vector_add( return vector_add(
plane.origin_mm, plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
) )
def tangent(value: Any) -> Vector3 | None: def local_vector(value: Any) -> Vector3:
if value is None: if plane is None:
return None raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2: if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates") raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
if segments is not None:
if segment:
raise ValueError("sweep path cannot mix segment and segments")
if not isinstance(segments, list) or len(segments) < 2:
raise ValueError("sweep segmented path requires at least two segments")
materialized: list[dict[str, Any]] = []
for index, source in enumerate(segments):
if not isinstance(source, dict):
raise ValueError("sweep path segment must be an object")
kind = str(source.get("type") or "")
if kind not in {"line", "arc", "bspline"}:
raise ValueError(f"unsupported sweep path segment {kind!r}")
target: dict[str, Any] = {"type": kind}
if spatial:
if kind in {"line", "arc"}:
target["start_mm"] = source.get("start_mm")
target["end_mm"] = source.get("end_mm")
if kind == "arc":
target["center_mm"] = source.get("center_mm")
target["normal"] = source.get("normal")
target["radius_mm"] = source.get("radius_mm")
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
target["points_mm"] = source.get("points_mm")
if source.get("start_tangent_mm") is not None:
target["start_tangent_mm"] = source.get("start_tangent_mm")
if source.get("end_tangent_mm") is not None:
target["end_tangent_mm"] = source.get("end_tangent_mm")
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
continue
if kind in {"line", "arc"}:
target["start_mm"] = local_point(source.get("start"))
target["end_mm"] = local_point(source.get("end"))
if kind == "arc":
target["center_mm"] = local_point(source.get("center"))
target["normal"] = plane.normal
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
points = source.get("points")
if not isinstance(points, list) or len(points) < 2:
raise ValueError("sweep B-spline path requires at least two interpolation points")
target["points_mm"] = [local_point(value) for value in points]
if source.get("start_tangent") is not None:
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
if source.get("end_tangent") is not None:
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
return session.adapter.sweep_path_segments(materialized)
if spatial:
raise ValueError("spatial sweep path requires captured segments")
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "circle":
return session.adapter.sweep_circle_path(
local_point(segment.get("center")),
plane.x_dir,
plane.normal,
radius_mm=float(segment["radius_mm"]),
)
elif kind == "arc":
return session.adapter.sweep_arc_path(
local_point(segment.get("start")),
local_point(segment.get("end")),
local_point(segment.get("center")),
plane.normal,
radius_mm=float(segment["radius_mm"]),
clockwise=bool(segment["clockwise"]),
)
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2:
raise ValueError("sweep path requires two-dimensional points")
return session.adapter.sweep_path( return session.adapter.sweep_path(
[point(value) for value in local_points], [local_point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")), start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
end_tangent=tangent(segment.get("end_tangent")), end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
parameters=[float(value) for value in segment.get("parameters") or []] or None, parameters=[float(value) for value in segment.get("parameters") or []] or None,
) )
@@ -343,6 +705,8 @@ def _register_added_solid(
session: "ExecutionSession", session: "ExecutionSession",
node: FeaturePlanNode, node: FeaturePlanNode,
solid: Any, solid: Any,
*,
topology_delta: TopologyDelta | None = None,
) -> None: ) -> None:
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
@@ -356,21 +720,62 @@ def _register_added_solid(
if node.params.get("result_mode") == "new_body": if node.params.get("result_mode") == "new_body":
combined = session.adapter.combine(session.body, solid) combined = session.adapter.combine(session.body, solid)
members = {**session.body_members, node.feature_id: solid} members = {**session.body_members, node.feature_id: solid}
session.register_body(node.feature_id, combined, replay_node=node, body_members=members) session.register_body(
node.feature_id, combined, replay_node=node, body_members=members,
topology_delta=topology_delta,
)
return return
fused = session.adapter.fuse(session.body, solid) if session.body is None:
session.register_body(node.feature_id, fused, replay_node=node) session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta)
return
if topology_delta is None:
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return
# Preserve primitive output roles only through the exact OCC fuse history.
# The transient records provide source handles, not selectable snapshots.
role_records = _direct_output_role_records(session, node, solid, topology_delta)
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid)
session.register_body(
node.feature_id, fused, replay_node=node, topology_delta=fuse_delta,
topology_predecessors=role_records,
)
def _host_plane(resolution: SelectorResolution) -> PlaneSpec: def _direct_output_role_records(
session: "ExecutionSession",
node: FeaturePlanNode,
solid: Any,
topology_delta: TopologyDelta,
) -> list[TopologyRecord]:
"""Attach only builder-proven output roles to a transient primitive snapshot."""
records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}")
result: list[TopologyRecord] = []
for record in records:
roles = {
relation.output_role
for relation in topology_delta.relations
if relation.output_role is not None
and relation.kind == record.kind
and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values)
}
if roles:
result.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=record.feature_id,
body_id=record.body_id,
geometry=record.geometry,
value=record.value,
owner_feature_ids=(node.feature_id,),
output_roles=tuple(sorted(roles)),
))
return result
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
if resolution.record is None: if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
geometry = resolution.record.geometry return adapter.planar_face_workplane(resolution.record.value)
return PlaneSpec.from_mapping({
"origin_mm": geometry["center_mm"],
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
"normal": geometry["normal"],
})
def _hole_starts( def _hole_starts(
@@ -415,10 +820,12 @@ def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tange
edges: list[Any] = [] edges: list[Any] = []
for item in resolved: for item in resolved:
if item.record.kind == "edge": records = item.records or ((item.record,) if item.record is not None else ())
edges.append(item.record.value) for record in records:
elif item.record.kind == "face": if record.kind == "edge":
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) edges.append(record.value)
elif record.kind == "face":
edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge))
if not edges: if not edges:
raise ValueError("selectors did not resolve any edges") raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
@@ -432,7 +839,11 @@ def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[A
failed = next((item for item in resolved if item.status != "resolved"), None) failed = next((item for item in resolved if item.status != "resolved"), None)
if failed: if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
records = [item.record for item in resolved if item.record is not None] records = [
record
for item in resolved
for record in (item.records or ((item.record,) if item.record is not None else ()))
]
if not records or any(record.kind != "face" for record in records): if not records or any(record.kind != "face" for record in records):
raise ValueError("shell selectors must resolve to faces") raise ValueError("shell selectors must resolve to faces")
target_ids = {record.body_id for record in records} target_ids = {record.body_id for record in records}
@@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors.
from __future__ import annotations from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor from ..registry import atomic_executor
@@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession",
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
session.topology.register_context(node.feature_id, axis) session.topology.register_context(node.feature_id, axis)
return session.result(node, context=axis) return session.result(node, context=axis)
@atomic_executor("reference_point")
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Execute an explicit source-datum point without changing model topology."""
del sketch
point = node.params.get("point_mm")
if (
not isinstance(point, list)
or len(point) != 3
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
):
raise ValueError("reference point requires one finite three-dimensional point")
return session.result(node, include_body=False)
@atomic_executor("assign_variable")
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Replay a source variable declaration after lowering has consumed it."""
del sketch
name = node.params.get("name")
value = node.params.get("value")
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError("assign_variable requires one finite named value")
return session.result(node, include_body=False)
@@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession from ..session import ExecutionSession
def _single_member_dressup_members(
node: FeaturePlanNode,
session: "ExecutionSession",
body: Any,
selected_edges: list[Any],
) -> dict[str, Any] | None:
"""Preserve unchanged body members after one exact-member dress-up.
The adapter only reports Compound history when every selected edge maps to
one source solid. Keep the same proof at the body-graph layer: all other
members must appear unchanged in the result and exactly one result solid
must remain for the changed member. Otherwise aggregate replay remains
valid, but no member-lifecycle transfer is asserted.
"""
if not selected_edges or len(session.body_members) < 2:
return None
source_members: dict[str, Any] = {}
for member_id, member in session.body_members.items():
solids = session.adapter.body_solids(member)
if len(solids) != 1:
return None
source_members[member_id] = solids[0]
selected_members = {
member_id
for edge in selected_edges
for member_id, member in source_members.items()
if any(edge.is_same(candidate) for candidate in member.edges())
}
if len(selected_members) != 1:
return None
changed_member_id = next(iter(selected_members))
result_solids = session.adapter.body_solids(body)
unchanged: dict[str, Any] = {}
matched_result_indexes: set[int] = set()
for member_id, member in source_members.items():
if member_id == changed_member_id:
continue
matches = [
index for index, result in enumerate(result_solids)
if session.topology._same_topology_value(member, result)
]
if len(matches) != 1 or matches[0] in matched_result_indexes:
return None
matched_result_indexes.add(matches[0])
unchanged[member_id] = result_solids[matches[0]]
changed = [
result for index, result in enumerate(result_solids)
if index not in matched_result_indexes
]
if len(changed) != 1:
return None
return {**unchanged, node.feature_id: changed[0]}
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu
if radius <= 0: if radius <= 0:
raise ValueError("fillet radius_mm must be > 0") raise ValueError("fillet radius_mm must be > 0")
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
body, topology_delta = session.adapter.fillet_with_topology_delta( body, topology_delta = session.adapter.fillet_with_topology_delta(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), session.body, radius, edges,
) )
# 4. 登记新主体并返回结果。 # 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node) return session.result(node)
@@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat
detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
)) ))
# 5. 登记新主体并返回结果。 # 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node, diagnostics=diagnostics) return session.result(node, diagnostics=diagnostics)
+40 -5
View File
@@ -27,10 +27,21 @@ def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sk
def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult: def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult:
# 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。
# 1. 校验:孔是切除操作,必须先有主体。 # 1. 校验:孔是切除操作,必须先有主体。
if session.body is None: if session.body is None:
raise ValueError("hole feature has no body") raise ValueError("hole feature has no body")
scope_feature_id = node.params.get("scope_feature_id")
scoped_body = None
if scope_feature_id is not None:
if not isinstance(scope_feature_id, str) or not scope_feature_id:
raise ValueError("hole scope_feature_id is invalid")
scoped_body = session.body_members.get(scope_feature_id)
if scoped_body is None:
raise ValueError("hole scope body is no longer an independently selectable member")
scoped_solids = session.adapter.body_solids(scoped_body)
if not scoped_solids:
raise ValueError("hole scope body has no active solid")
# 2. 确定宿主面 host_face: # 2. 确定宿主面 host_face:
host_selector = node.params.get("host_face") host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
@@ -45,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
selector = next((item for item in selectors if item.get("kind") == "face"), None) selector = next((item for item in selectors if item.get("kind") == "face"), None)
if selector is None: if selector is None:
raise ValueError("hole requires host_face selector or frame") raise ValueError("hole requires host_face selector or frame")
host = _host_plane(session.resolve(selector)) host = _host_plane(session.resolve(selector), session.adapter)
positions_are_local = False intent = selector.get("selector_intent") if isinstance(selector, dict) else None
# Existing selector-hosted holes store world positions. Only the new
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
# until its exact host relation has materialized.
positions_are_local = (
isinstance(intent, dict)
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
)
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
# 4. A host-face normal is an outward B-rep orientation, so its inverse # 4. A host-face normal is an outward B-rep orientation, so its inverse
@@ -61,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
spec, spec,
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
inward, inward,
session.adapter.body_span(session.body, inward) + 2.0, session.adapter.body_span(scoped_body or session.body, inward) + 2.0,
) )
# 6. 从主体上减去工具实体,登记新主体并返回结果。 # 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
@@ -74,5 +92,22 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id, feature_id=node.feature_id,
)) ))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) if scope_feature_id is None:
result_body = session.adapter.cut(session.body, tool)
members = None
else:
# CADFS ``scope`` identifies the target body member. Never apply the
# cutter to the aggregate merely because unrelated live members share
# the exported part; unchanged members remain exact body-graph nodes.
result_body = session.adapter.cut(scoped_body, tool)
members = {**session.body_members, scope_feature_id: result_body}
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("hole scope cut produced no active body")
result_body = body
session.register_body(
node.feature_id, result_body, replay_node=node, body_members=members,
)
return session.result(node, diagnostics=diagnostics) return session.result(node, diagnostics=diagnostics)
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord
from .common import _sweep_path from .common import _apply_primary_tool, _sweep_path
if TYPE_CHECKING: # pragma: no cover - import for type checkers only if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession from ..session import ExecutionSession
@@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
profile = sketch or session.sketches.get(str(node.sketch_id)) profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None: if profile is None:
raise ValueError("sweep has no resolved profile sketch") raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile) faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile)
if len(faces) != 1: if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region") raise ValueError("sweep requires exactly one closed profile region")
solid, topology_delta = session.adapter.sweep_with_topology_delta( solid, topology_delta = session.adapter.sweep_with_topology_delta(
faces[0], _sweep_path(node, session), faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)), is_frenet=bool(node.params.get("is_frenet", False)),
) )
if node.atomic_id == "sweep_cut":
# The PipeShell is a transient cutting tool. Its own builder history
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
# the cut's exact target-side delta may survive registration.
return _apply_primary_tool(node, session, solid, cutting=True)
is_new_body = node.params.get("result_mode") == "new_body" is_new_body = node.params.get("result_mode") == "new_body"
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
# A union rebuilds topology, so the pipe-shell builder cannot prove the # A union rebuilds topology, so the pipe-shell builder cannot prove the
@@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
# subshape identity and may expose evidence for the new member. # subshape identity and may expose evidence for the new member.
if session.body is not None and not is_new_body: if session.body is not None and not is_new_body:
topology_delta = None topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) topology_anchors: list[TopologyRecord] = []
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
if kind not in {"edge", "vertex"} or spec.get("value") is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if not isinstance(source_entity, tuple) and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=spec["value"],
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
return session.result(node) return session.result(node)
@atomic_executor("sweep_add") @atomic_executor("sweep_add")
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch) return _execute_sweep_add(node, session, sketch)
@atomic_executor("sweep_cut")
def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@@ -40,7 +40,7 @@ def _execute_linear_pattern(
execute: ExecutorFunction, execute: ExecutorFunction,
) -> FeatureResult: ) -> FeatureResult:
# 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。
# 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。
params = node.params params = node.params
sources = session.replay_sources(params.get("source_feature_ids") or []) sources = session.replay_sources(params.get("source_feature_ids") or [])
@@ -73,9 +73,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> Fea
raise ValueError("cylinder_add axis must define origin_mm and direction") raise ValueError("cylinder_add axis must define origin_mm and direction")
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
solid = session.adapter.cylinder(radius, height, axis) solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis)
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid) _register_added_solid(session, node, solid, topology_delta=topology_delta)
return session.result(node) return session.result(node)
@@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession")
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch del sketch
return _execute_extrude_surface(node, session) return _execute_extrude_surface(node, session)
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
profile = session.sketches.get(str(sketch_id))
if profile is None:
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
profiles.append(profile)
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
return session.result(node, include_body=False, surface_id=surface_id)
@atomic_executor("loft_surface")
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_surface(node, session)
+34 -3
View File
@@ -8,6 +8,7 @@ selector resolution, never on executors.
from __future__ import annotations from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from .runtime_base import ExtentVector, FeatureExecutionError from .runtime_base import ExtentVector, FeatureExecutionError
@@ -44,12 +45,29 @@ def _targeted_extent_vector(
end_condition: dict[str, Any] | None = None, end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None, offset_mm: float | None = None,
) -> ExtentVector: ) -> ExtentVector:
if session.body is None: reference = _extent_reference(node, end_condition) if condition != "through_next" else None
source_vertex_point: Vector3 | None = None
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
raw_point = reference.get("point_mm")
if not (
isinstance(raw_point, list)
and len(raw_point) == 3
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
):
raise FeatureExecutionError(
"invalid_source_vertex_extent",
"The source-vertex extent datum must contain one finite 3D point",
extent=condition,
)
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
elif session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next": if condition == "through_next":
target = session.body target = session.body
elif source_vertex_point is not None:
target = None
else: else:
reference = _extent_reference(node, end_condition) assert isinstance(reference, dict)
resolution = session.resolve(reference) resolution = session.resolve(reference)
if resolution.status != "resolved" or resolution.record is None: if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
@@ -62,7 +80,7 @@ def _targeted_extent_vector(
) )
target = resolution.record.value target = resolution.record.value
if condition == "up_to_vertex": if condition == "up_to_vertex":
target_point = session.adapter.vertex_coordinates(target) target_point = source_vertex_point or session.adapter.vertex_coordinates(target)
projections = [ projections = [
vector_dot(vector_subtract(target_point, point), direction) vector_dot(vector_subtract(target_point, point), direction)
for face in faces for face in faces
@@ -89,6 +107,19 @@ def _targeted_extent_vector(
distance = session.adapter.uniform_intersection_distance(target, faces, direction) distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error: except ValueError as error:
message = str(error) message = str(error)
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
# Do not reinterpret a partially hit finite face: that path
# has explicit trimmed-solid semantics below. Only a wholly
# unreachable planar face may terminate on its supporting
# plane, and the adapter proves one positive, uniform
# profile-to-plane distance before returning it.
if not session.adapter.target_has_forward_intersection(target, faces, direction):
try:
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
except ValueError:
pass
else:
return ExtentVector(vector_scale(direction, distance))
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition in {"up_to_surface", "through_next"}: if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
@@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
params_schema = raw.get("author_params_schema") params_schema = raw.get("author_params_schema")
injected_paths = raw.get("server_injected_paths") injected_paths = raw.get("server_injected_paths")
selector_policy = raw.get("selector_policy") selector_policy = raw.get("selector_policy")
nested_selector_policies = raw.get("nested_selector_policies") or {}
runtime_capability = raw.get("runtime_capability") runtime_capability = raw.get("runtime_capability")
if ( if (
not isinstance(shape, dict) not isinstance(shape, dict)
or not isinstance(params_schema, dict) or not isinstance(params_schema, dict)
or not isinstance(injected_paths, list) or not isinstance(injected_paths, list)
or not isinstance(selector_policy, dict) or not isinstance(selector_policy, dict)
or not isinstance(nested_selector_policies, dict)
): ):
raise ValueError(f"operation contract is incomplete for {atomic_id}") raise ValueError(f"operation contract is incomplete for {atomic_id}")
if not isinstance(runtime_capability, dict) or not all( if not isinstance(runtime_capability, dict) or not all(
@@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
raise ValueError(f"operation author params are invalid for {atomic_id}") raise ValueError(f"operation author params are invalid for {atomic_id}")
if not all(isinstance(name, str) and name in properties for name in required): if not all(isinstance(name, str) and name in properties for name in required):
raise ValueError(f"operation required params are invalid for {atomic_id}") raise ValueError(f"operation required params are invalid for {atomic_id}")
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
materialized_required = list(dict.fromkeys(required)) materialized_required = list(dict.fromkeys(required))
for path in injected_paths: for path in injected_paths:
if not isinstance(path, str): if not isinstance(path, str):
@@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
"requires_sketch": shape.get("sketch") == "required", "requires_sketch": shape.get("sketch") == "required",
"selector_slot": selector_policy.get("slot"), "selector_slot": selector_policy.get("slot"),
"selector_token_kind": selector_policy.get("token_kind"), "selector_token_kind": selector_policy.get("token_kind"),
"nested_selector_policies": dict(nested_selector_policies),
"runtime_capability": dict(runtime_capability), "runtime_capability": dict(runtime_capability),
} }
return derived return derived
@@ -409,6 +409,28 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
for key in ("x_dir", "y_dir", "normal"): for key in ("x_dir", "y_dir", "normal"):
if path_plane.get(key): if path_plane.get(key):
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
elif isinstance(path, dict) and isinstance(path.get("segments"), list):
# A source-only spatial sweep path has no common workplane. Its
# captured points and directions are absolute, so replayed circular
# pattern instances must rotate each geometric field independently.
for segment in path["segments"]:
if not isinstance(segment, dict):
continue
for key in ("start_mm", "end_mm", "center_mm"):
value = segment.get(key)
if isinstance(value, list) and len(value) == 3:
segment[key] = _rotated_point(value, axis, angle_rad)
points = segment.get("points_mm")
if isinstance(points, list):
segment["points_mm"] = [
_rotated_point(point, axis, angle_rad)
for point in points
if isinstance(point, list) and len(point) == 3
]
for key in ("normal", "start_tangent_mm", "end_tangent_mm"):
value = segment.get(key)
if isinstance(value, list) and len(value) == 3:
segment[key] = list(_rotated_vector(tuple(float(v) for v in value), axis, angle_rad))
host = params.get("host_face") host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all( positions_are_local = isinstance(host_frame, dict) and all(
+15 -7
View File
@@ -4,17 +4,19 @@
"cdsl_json_schema_file": "cdsl_schema.json", "cdsl_json_schema_file": "cdsl_schema.json",
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.", "maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"], "runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"],
"operation_contracts": { "operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"loft_surface": {"atomic_id":"loft_surface","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":2,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
@@ -33,6 +35,8 @@
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"hole_wizard": { "hole_wizard": {
"runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false}, "runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false},
"atomic_id": "hole_wizard", "atomic_id": "hole_wizard",
@@ -67,11 +71,11 @@
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, "shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"},"targetless_body_set":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"transform_bodies": { "transform_bodies": {
"runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false}, "runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false},
"atomic_id": "transform_bodies", "atomic_id": "transform_bodies",
"contract_version": "3.3", "contract_version": "3.4",
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"}, "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"},
"author_params_schema": { "author_params_schema": {
"type": "object", "type": "object",
@@ -79,6 +83,7 @@
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true}, "source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true}, "pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true}, "transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
"source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
"transform": { "transform": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -129,6 +134,9 @@
"analytic_contours": { "analytic_contours": {
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry." "summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
}, },
"multi_source_regions": {
"summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor."
},
"planar_imprint": { "planar_imprint": {
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour." "summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
} }
+343 -343
View File
@@ -1,343 +1,343 @@
""" """
CDSL → STEP 重建管道 CDSL → STEP 重建管道
==================== ====================
优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only) 优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
回退: CDSL + compiler_context → translator 回退: CDSL + compiler_context → translator
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
try: try:
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from .llm_compiler import compile_cdsl from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan from .llm_engine import run_engine_plan
from .translator import generate_build123d_code, normalize_to_ir from .translator import generate_build123d_code, normalize_to_ir
from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
except ImportError: # 允许直接 python rebuild.py except ImportError: # 允许直接 python rebuild.py
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from llm_compiler import compile_cdsl from llm_compiler import compile_cdsl
from llm_engine import run_engine_plan from llm_engine import run_engine_plan
from translator import generate_build123d_code, normalize_to_ir from translator import generate_build123d_code, normalize_to_ir
from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
force_exact: bool = False) -> dict[str, Any]: force_exact: bool = False) -> dict[str, Any]:
"""主重建入口。 """主重建入口。
优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。
回退:CDSL + compiler_context 的 translator 路径。 回退:CDSL + compiler_context 的 translator 路径。
""" """
# Compatibility entry point only: new CDSL-only runtime calls do not use # Compatibility entry point only: new CDSL-only runtime calls do not use
# macro profiles and therefore never invoke this adapter. # macro profiles and therefore never invoke this adapter.
cdsl = lower_legacy_profiles(cdsl) cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", []) sketches = cdsl.get("geometry", {}).get("sketches", [])
# Sketchless parameterized features (for example sphere_add) are fully # Sketchless parameterized features (for example sphere_add) are fully
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps # executable by the CDSL-only runtime. ``all([])`` deliberately keeps
# that path available rather than forcing an unavailable legacy fallback. # that path available rather than forcing an unavailable legacy fallback.
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
cdsl_only_error: Exception | None = None cdsl_only_error: Exception | None = None
if all_drawable and not force_exact: if all_drawable and not force_exact:
try: try:
return run_cdsl_only(cdsl, out_step, gold_step=gold_step) return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
except Exception as e: except Exception as e:
cdsl_only_error = e cdsl_only_error = e
# 加载 compiler_context(后备路径) # 加载 compiler_context(后备路径)
ctx = None ctx = None
if ctx_file and ctx_file.exists(): if ctx_file and ctx_file.exists():
with open(ctx_file, "r", encoding="utf-8") as f: with open(ctx_file, "r", encoding="utf-8") as f:
ctx = json.load(f) ctx = json.load(f)
if ctx is None: if ctx is None:
part_id = cdsl.get("part_id", "") part_id = cdsl.get("part_id", "")
sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json" sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json"
if sw_json.exists(): if sw_json.exists():
with open(sw_json, "r", encoding="utf-8") as f: with open(sw_json, "r", encoding="utf-8") as f:
sw_data = json.load(f) sw_data = json.load(f)
ir = normalize_to_ir(sw_data) ir = normalize_to_ir(sw_data)
ctx = { ctx = {
"version": ir.get("version", "ir-0.1"), "version": ir.get("version", "ir-0.1"),
"metadata": ir.get("metadata", {}), "metadata": ir.get("metadata", {}),
"sketches": ir.get("sketches", []), "sketches": ir.get("sketches", []),
"operations": ir.get("operations", []), "operations": ir.get("operations", []),
"references": ir.get("references", []), "references": ir.get("references", []),
"validation_hints": ir.get("validation_hints", {}), "validation_hints": ir.get("validation_hints", {}),
} }
if ctx is None and not (cdsl.get("compiler_context")): if ctx is None and not (cdsl.get("compiler_context")):
if cdsl_only_error is not None: if cdsl_only_error is not None:
raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error
raise RuntimeError( raise RuntimeError(
"CDSL-only rebuild is unavailable: every sketch must use a supported " "CDSL-only rebuild is unavailable: every sketch must use a supported "
"self-contained profile." "self-contained profile."
) )
if ctx is not None: if ctx is not None:
cdsl["compiler_context"] = ctx cdsl["compiler_context"] = ctx
has_profiled = any( has_profiled = any(
s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm") s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm")
for s in sketches for s in sketches
) )
if has_profiled and not force_exact: if has_profiled and not force_exact:
try: try:
return _run_parameterized(cdsl, out_step, gold_step=gold_step) return _run_parameterized(cdsl, out_step, gold_step=gold_step)
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
print(f" [WARN] parameterized path failed: {e}, falling back to exact") print(f" [WARN] parameterized path failed: {e}, falling back to exact")
return _run_exact(cdsl, out_step, gold_step) return _run_exact(cdsl, out_step, gold_step)
def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool:
"""草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。""" """草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。"""
if sketch.get("profile_from"): if sketch.get("profile_from"):
return True return True
profile = sketch.get("profile") profile = sketch.get("profile")
if not profile: if not profile:
return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm")) return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm"))
ptype = profile.get("type") ptype = profile.get("type")
if ptype in ("complex_arc_shape", "unknown_shape"): if ptype in ("complex_arc_shape", "unknown_shape"):
return False return False
if ptype == "polygon": if ptype == "polygon":
return bool(profile.get("vertices")) return bool(profile.get("vertices"))
return ptype in SHAPE_GENERATORS return ptype in SHAPE_GENERATORS
def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""Pure semantic CDSL path with no compiler_context fallback.""" """Pure semantic CDSL path with no compiler_context fallback."""
t0 = time.time() t0 = time.time()
try: try:
from .runtime import rebuild_cdsl from .runtime import rebuild_cdsl
except ImportError: except ImportError:
from runtime import rebuild_cdsl from runtime import rebuild_cdsl
slim = {key: value for key, value in cdsl.items() if key != "compiler_context"} slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
result = rebuild_cdsl(slim, out_step, strict=True) result = rebuild_cdsl(slim, out_step, strict=True)
result["engine"] = "cdsl_only" result["engine"] = "cdsl_only"
result["elapsed_s"] = round(time.time() - t0, 1) result["elapsed_s"] = round(time.time() - t0, 1)
result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)") result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
if gold_step and gold_step.exists(): if gold_step and gold_step.exists():
result["gold_step"] = str(gold_step) result["gold_step"] = str(gold_step)
return result return result
def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"})
pack.pop("compiler_context", None) pack.pop("compiler_context", None)
return pack return pack
def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]:
return run_engine_plan(pack, out_step) return run_engine_plan(pack, out_step)
def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]:
from build123d import import_step from build123d import import_step
import math, random, time import math, random, time
gold = import_step(str(gold_step)) gold = import_step(str(gold_step))
rebuilt = import_step(str(rebuilt_step)) rebuilt = import_step(str(rebuilt_step))
gv = float(gold.volume) gv = float(gold.volume)
rv = float(rebuilt.volume) rv = float(rebuilt.volume)
rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0 rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0
gb = gold.bounding_box() gb = gold.bounding_box()
rb = rebuilt.bounding_box() rb = rebuilt.bounding_box()
bbox_delta = max( bbox_delta = max(
abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y), abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y),
abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X), abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X),
abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z), abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z),
) )
shape_deltas = _surface_deviation(gold, rebuilt, n_points=500) shape_deltas = _surface_deviation(gold, rebuilt, n_points=500)
shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999) shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999)
shape_median = shape_deltas.get("shape_median_delta_mm", 999) shape_median = shape_deltas.get("shape_median_delta_mm", 999)
over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100) over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100)
# 形状一致性分级(形状为主,体积/包围盒仅作参考) # 形状一致性分级(形状为主,体积/包围盒仅作参考)
if shape_p99 <= 1.0: if shape_p99 <= 1.0:
shape_grade = "A" # 完美形状匹配 shape_grade = "A" # 完美形状匹配
elif shape_p99 <= 6.0: elif shape_p99 <= 6.0:
shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差) shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差)
elif shape_p99 <= 8.0: elif shape_p99 <= 8.0:
shape_grade = "C" # 可接受 shape_grade = "C" # 可接受
else: else:
shape_grade = "F" # 形状偏差过大 shape_grade = "F" # 形状偏差过大
# 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm # 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm
shape_pass = shape_p99 <= 6.0 shape_pass = shape_p99 <= 6.0
vol_sane = rel_err <= 10.0 vol_sane = rel_err <= 10.0
bbox_sane = bbox_delta <= 2.0 bbox_sane = bbox_delta <= 2.0
passed = shape_pass and vol_sane and bbox_sane passed = shape_pass and vol_sane and bbox_sane
report = { report = {
"gold_volume_mm3": gv, "gold_volume_mm3": gv,
"rebuilt_volume_mm3": rv, "rebuilt_volume_mm3": rv,
"volume_rel_err_pct": round(rel_err, 4), "volume_rel_err_pct": round(rel_err, 4),
"gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z], "gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z],
"max": [gb.max.X, gb.max.Y, gb.max.Z]}, "max": [gb.max.X, gb.max.Y, gb.max.Z]},
"rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z], "rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z],
"max": [rb.max.X, rb.max.Y, rb.max.Z]}, "max": [rb.max.X, rb.max.Y, rb.max.Z]},
"bbox_max_delta_mm": round(bbox_delta, 4), "bbox_max_delta_mm": round(bbox_delta, 4),
**shape_deltas, **shape_deltas,
"shape_grade": shape_grade, "shape_grade": shape_grade,
"passed": passed, "passed": passed,
} }
if not passed: if not passed:
reasons = [] reasons = []
if not shape_pass: if not shape_pass:
reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm") reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm")
if not vol_sane: if not vol_sane:
reasons.append(f"vol_err={rel_err:.1f}% > 10%") reasons.append(f"vol_err={rel_err:.1f}% > 10%")
if not bbox_sane: if not bbox_sane:
reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm") reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm")
report["fail_reasons"] = " | ".join(reasons) report["fail_reasons"] = " | ".join(reasons)
return report return report
def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]:
"""用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差""" """用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差"""
from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
import random, math import random, math
random.seed(42) random.seed(42)
def sample_points(solid, max_n): def sample_points(solid, max_n):
pts = [] pts = []
for v in solid.vertices(): for v in solid.vertices():
pts.append((float(v.X), float(v.Y), float(v.Z))) pts.append((float(v.X), float(v.Y), float(v.Z)))
for e in solid.edges(): for e in solid.edges():
try: try:
c = e.center() c = e.center()
pts.append((float(c.X), float(c.Y), float(c.Z))) pts.append((float(c.X), float(c.Y), float(c.Z)))
except Exception: except Exception:
pass pass
if len(pts) > max_n: if len(pts) > max_n:
pts = random.sample(pts, max_n) pts = random.sample(pts, max_n)
return pts return pts
def point_to_solid_dist(px, py, pz, solid_wrapped): def point_to_solid_dist(px, py, pz, solid_wrapped):
vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex() vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex()
ds = BRepExtrema_DistShapeShape() ds = BRepExtrema_DistShapeShape()
ds.LoadS1(vertex) ds.LoadS1(vertex)
ds.LoadS2(solid_wrapped) ds.LoadS2(solid_wrapped)
ds.Perform() ds.Perform()
if ds.IsDone() and ds.NbSolution() > 0: if ds.IsDone() and ds.NbSolution() > 0:
return ds.Value() return ds.Value()
return float('inf') return float('inf')
gw = gold.wrapped gw = gold.wrapped
rw = rebuilt.wrapped rw = rebuilt.wrapped
pts_g = sample_points(gold, n_points) pts_g = sample_points(gold, n_points)
pts_r = sample_points(rebuilt, n_points) pts_r = sample_points(rebuilt, n_points)
deltas = [] deltas = []
for (px, py, pz) in pts_g: for (px, py, pz) in pts_g:
d = point_to_solid_dist(px, py, pz, rw) d = point_to_solid_dist(px, py, pz, rw)
if d < float('inf'): if d < float('inf'):
deltas.append(d) deltas.append(d)
for (px, py, pz) in pts_r: for (px, py, pz) in pts_r:
d = point_to_solid_dist(px, py, pz, gw) d = point_to_solid_dist(px, py, pz, gw)
if d < float('inf'): if d < float('inf'):
deltas.append(d) deltas.append(d)
if not deltas: if not deltas:
return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0, return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0,
"shape_median_delta_mm": 0.0, "shape_n_samples": 0} "shape_median_delta_mm": 0.0, "shape_n_samples": 0}
deltas.sort() deltas.sort()
n = len(deltas) n = len(deltas)
mean_d = sum(deltas) / n mean_d = sum(deltas) / n
max_d = deltas[-1] max_d = deltas[-1]
median_d = deltas[n // 2] median_d = deltas[n // 2]
p90 = deltas[int(n * 0.9)] if n > 10 else max_d p90 = deltas[int(n * 0.9)] if n > 10 else max_d
p95 = deltas[int(n * 0.95)] if n > 20 else max_d p95 = deltas[int(n * 0.95)] if n > 20 else max_d
p99 = deltas[int(n * 0.99)] if n > 100 else max_d p99 = deltas[int(n * 0.99)] if n > 100 else max_d
over_01mm = sum(1 for d in deltas if d > 0.01) over_01mm = sum(1 for d in deltas if d > 0.01)
over_05mm = sum(1 for d in deltas if d > 0.5) over_05mm = sum(1 for d in deltas if d > 0.5)
over_pct = round(over_05mm / n * 100, 1) if n else 0 over_pct = round(over_05mm / n * 100, 1) if n else 0
return { return {
"shape_mean_delta_mm": round(mean_d, 4), "shape_mean_delta_mm": round(mean_d, 4),
"shape_max_delta_mm": round(max_d, 4), "shape_max_delta_mm": round(max_d, 4),
"shape_median_delta_mm": round(median_d, 4), "shape_median_delta_mm": round(median_d, 4),
"shape_p90_delta_mm": round(p90, 4), "shape_p90_delta_mm": round(p90, 4),
"shape_p95_delta_mm": round(p95, 4), "shape_p95_delta_mm": round(p95, 4),
"shape_p99_delta_mm": round(p99, 4), "shape_p99_delta_mm": round(p99, 4),
"shape_n_samples": n, "shape_n_samples": n,
"shape_n_over_0.01mm": over_01mm, "shape_n_over_0.01mm": over_01mm,
"shape_n_over_0.5mm": over_05mm, "shape_n_over_0.5mm": over_05mm,
"shape_over_0.5mm_pct": over_pct, "shape_over_0.5mm_pct": over_pct,
} }
# 保留旧版本的_sample_surface_points清理掉 # 保留旧版本的_sample_surface_points清理掉
# (下面的不再需要,新逻辑已在_surface_deviation中实现) # (下面的不再需要,新逻辑已在_surface_deviation中实现)
# =========================================================================== # ===========================================================================
# CLI(便携:显式路径,无项目目录假设) # CLI(便携:显式路径,无项目目录假设)
# =========================================================================== # ===========================================================================
def main(): def main():
import argparse import argparse
ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)") ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)")
ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path") ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path")
ap.add_argument("--out", type=Path, required=True, help="output STEP path") ap.add_argument("--out", type=Path, required=True, help="output STEP path")
ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP") ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP")
ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context") ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context")
ap.add_argument("--force-exact", action="store_true") ap.add_argument("--force-exact", action="store_true")
ap.add_argument("--report", type=Path, default=None) ap.add_argument("--report", type=Path, default=None)
args = ap.parse_args() args = ap.parse_args()
cdsl = json.loads(args.cdsl.read_text(encoding="utf-8")) cdsl = json.loads(args.cdsl.read_text(encoding="utf-8"))
out_step = args.out out_step = args.out
out_step.parent.mkdir(parents=True, exist_ok=True) out_step.parent.mkdir(parents=True, exist_ok=True)
print(f"Rebuild: {args.cdsl} -> {out_step}") print(f"Rebuild: {args.cdsl} -> {out_step}")
try: try:
result = run_rebuild( result = run_rebuild(
cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact
) )
except Exception as e: except Exception as e:
print(f"REBUILD ERROR: {e}") print(f"REBUILD ERROR: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)
print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3") print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3")
report = { report = {
"cdsl_path": str(args.cdsl), "cdsl_path": str(args.cdsl),
"rebuilt_step": str(out_step), "rebuilt_step": str(out_step),
"engine_result": result, "engine_result": result,
} }
status = "OK" status = "OK"
if args.gold and args.gold.exists(): if args.gold and args.gold.exists():
comp = compare_with_gold(args.gold, out_step) comp = compare_with_gold(args.gold, out_step)
report["comparison"] = comp report["comparison"] = comp
status = "PASS" if comp["passed"] else "FAIL" status = "PASS" if comp["passed"] else "FAIL"
print( print(
f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% " f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% "
f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}" f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}"
) )
report_path = args.report or out_step.with_suffix(".rebuild_report.json") report_path = args.report or out_step.with_suffix(".rebuild_report.json")
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
print(f"Report: {report_path}") print(f"Report: {report_path}")
print(f"Final: {status}") print(f"Final: {status}")
if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True): if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True):
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+2 -2
View File
@@ -29,10 +29,10 @@ class AtomicExecutor(Protocol):
#: at import time instead of surfacing as an unknown-atomic blocker later. #: at import time instead of surfacing as an unknown-atomic blocker later.
ALL_ATOMIC_IDS = frozenset({ ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add", "hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis", "reference_plane", "reference_axis", "reference_point", "assign_variable",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
"thread_add", "thread_cut", "thread_add", "thread_cut",
+103 -38
View File
@@ -20,6 +20,7 @@ working.
from __future__ import annotations from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
# Historical private name still imported by the test suite. # Historical private name still imported by the test suite.
from .executors.primitives import _execute_box # noqa: F401 from .executors.primitives import _execute_box # noqa: F401
from .executors.common import _selector_edges # noqa: F401
from .extents import ( from .extents import (
_extent_reference, _extent_reference,
_extent_vectors, _extent_vectors,
@@ -73,6 +75,9 @@ from .topology import (
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
) )
# Historical private imports used by integration tests and local diagnostics.
_execute_node = execute_node
__all__ = [ __all__ = [
"ALL_ATOMIC_IDS", "ALL_ATOMIC_IDS",
"EXECUTORS", "EXECUTORS",
@@ -85,7 +90,10 @@ __all__ = [
"GeometryAdapter", "GeometryAdapter",
"RuntimeDiagnostic", "RuntimeDiagnostic",
"RuntimeExecutionError", "RuntimeExecutionError",
"IncrementalCdslExecution",
"analyze_cdsl", "analyze_cdsl",
"prepare_cdsl_execution",
"finalize_cdsl_execution",
"execute_node", "execute_node",
"rebuild_cdsl", "rebuild_cdsl",
] ]
@@ -101,8 +109,83 @@ def analyze_cdsl(cdsl: dict[str, Any]):
return analyzer.analyze(resolved, sketch_errors=sketch_errors) return analyzer.analyze(resolved, sketch_errors=sketch_errors)
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]: def _preflight_error(analysis: Any) -> ValueError | None:
"""Rebuild CDSL through session-scoped atomic executors only.""" if analysis.runtime_eligible:
return None
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
return ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
return ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
def _execution_diagnostic(
error: Exception,
node: FeaturePlanNode,
session: ExecutionSession,
) -> RuntimeDiagnostic:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
if isinstance(error, FeatureExecutionError):
return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if failed_resolution and failed_resolution.get("diagnostic"):
diagnostic = failed_resolution["diagnostic"]
return RuntimeDiagnostic(
diagnostic["code"], diagnostic["message"], feature_id=node.feature_id,
detail=diagnostic.get("detail") or {},
)
return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
@dataclass
class IncrementalCdslExecution:
"""One prepared CDSL replay with feature-at-a-time execution.
CADFS selector binding consumes this object before each feature executes.
It therefore observes the exact session topology snapshots generated by
the one real kernel replay rather than rebuilding a growing prefix for
every selector. The generic runtime also uses it through ``rebuild_cdsl``.
"""
resolved_cdsl: dict[str, Any]
analysis: Any
session: ExecutionSession
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
next_index: int = 0
def execute_next(self, *, strict: bool = True) -> FeatureResult | None:
if self.next_index >= len(self.analysis.plan):
raise ValueError("CDSL execution plan is already complete")
node = self.analysis.plan[self.next_index]
preflight = self.analysis.feature_results[self.next_index]
self.next_index += 1
if not preflight.executable:
self.diagnostics.extend(preflight.blockers)
if strict:
detail = "; ".join(blocker.code for blocker in preflight.blockers)
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
return None
try:
if node.sketch_id is not None:
self.session.resolve_sketch_attachment(str(node.sketch_id), feature_id=node.feature_id)
return execute_node(node, self.session)
except Exception as error:
diagnostic = _execution_diagnostic(error, node, self.session)
self.diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error
return None
def execute_all(self, *, strict: bool = True) -> None:
while self.next_index < len(self.analysis.plan):
self.execute_next(strict=strict)
def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution:
"""Resolve CDSL once and return a reusable sequential execution session."""
sketch_errors: dict[str, str] = {} sketch_errors: dict[str, str] = {}
resolved = resolve_required_sketches( resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
@@ -110,45 +193,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
resolved, sketch_errors=sketch_errors, resolved, sketch_errors=sketch_errors,
) )
if strict and not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
session = ExecutionSession( session = ExecutionSession(
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []}, sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
nodes={node.feature_id: node for node in analysis.plan}, nodes={node.feature_id: node for node in analysis.plan},
) )
diagnostics: list[RuntimeDiagnostic] = [] return IncrementalCdslExecution(resolved, analysis, session)
for node, preflight in zip(analysis.plan, analysis.feature_results):
if not preflight.executable:
diagnostics.extend(preflight.blockers) def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
if strict: """Export the current executable checkpoint from an incremental replay."""
break session = execution.session
continue
try:
execute_node(node, session)
except Exception as error:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
diagnostic = (
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if isinstance(error, FeatureExecutionError)
else
RuntimeDiagnostic(
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
)
if failed_resolution and failed_resolution.get("diagnostic")
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
)
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
output = session.body output = session.body
surface_geometry: dict[str, Any] | None = None surface_geometry: dict[str, Any] | None = None
if output is None: if output is None:
@@ -177,8 +231,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0, "surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0, "surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
"feature_results": [result.as_dict() for result in session.results.values()], "feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()], "topology_records": [record.public_dict() for record in session.topology.records()],
"topology_deltas": list(session.topology.topology_deltas()), "topology_deltas": list(session.topology.topology_deltas()),
"selector_resolution": session.selector_resolutions, "selector_resolution": session.selector_resolutions,
} }
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
execution = prepare_cdsl_execution(cdsl)
if strict:
error = _preflight_error(execution.analysis)
if error is not None:
raise error
execution.execute_all(strict=strict)
return finalize_cdsl_execution(execution, out_step)
@@ -43,6 +43,9 @@ from .topology import (
SelectorResolution, SelectorResolution,
TopologyDelta, TopologyDelta,
TopologyDeltaRelation, TopologyDeltaRelation,
TopologyBlendRelation,
TopologySectionRelation,
TopologyLineage,
TopologyRecord, TopologyRecord,
TopologyRegistry, TopologyRegistry,
) )
@@ -63,6 +66,9 @@ __all__ = [
"ThreadSpec", "ThreadSpec",
"TopologyDelta", "TopologyDelta",
"TopologyDeltaRelation", "TopologyDeltaRelation",
"TopologyBlendRelation",
"TopologySectionRelation",
"TopologyLineage",
"TopologyRecord", "TopologyRecord",
"TopologyRegistry", "TopologyRegistry",
"Vector3", "Vector3",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+157 -5
View File
@@ -14,6 +14,7 @@ from typing import Any, Protocol
from .build123d_adapter import Build123dGeometryAdapter from .build123d_adapter import Build123dGeometryAdapter
from .runtime_base import FeatureExecutionError from .runtime_base import FeatureExecutionError
from .sketch_solver import resolve_profile
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3 from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
from .topology import ( from .topology import (
FeaturePlanNode, FeaturePlanNode,
@@ -23,6 +24,7 @@ from .topology import (
TopologyDelta, TopologyDelta,
TopologyRecord, TopologyRecord,
TopologyRegistry, TopologyRegistry,
validate_selector_provenance_intent,
) )
@@ -37,17 +39,25 @@ class GeometryAdapter(Protocol):
def body_solids(self, body: Any) -> list[Any]: ... def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ... def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ... def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... def faces_for_sketch(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> tuple[list[Any], list[dict[str, Any]]]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ... def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
def loft_surface(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ...
def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ...
def face_normal(self, face: Any) -> Vector3: ... def face_normal(self, face: Any) -> Vector3: ...
def planar_face_workplane(self, face: Any) -> PlaneSpec: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ... def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ...
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
@@ -55,6 +65,7 @@ class GeometryAdapter(Protocol):
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ... def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ... def intersect(self, left: Any, right: Any) -> Any: ...
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
@@ -66,6 +77,7 @@ class GeometryAdapter(Protocol):
def cut(self, body: Any, tool: Any) -> Any: ... def cut(self, body: Any, tool: Any) -> Any: ...
def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ...
def bend_solid(self, spec: BendSpec) -> Any: ... def bend_solid(self, spec: BendSpec) -> Any: ...
def gear_solid(self, spec: GearSpec) -> Any: ... def gear_solid(self, spec: GearSpec) -> Any: ...
@@ -79,6 +91,8 @@ class GeometryAdapter(Protocol):
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ...
def uniform_planar_supporting_surface_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
@@ -101,10 +115,85 @@ class ExecutionSession:
results: dict[str, FeatureResult] = field(default_factory=dict) results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict) body_members: dict[str, Any] = field(default_factory=dict)
body_member_snapshot_ids: dict[str, str] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict) surface_members: dict[str, Any] = field(default_factory=dict)
sketch_attachment_faces: dict[str, Any] = field(default_factory=dict)
sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list) selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = "" active_feature_id: str = ""
def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None:
"""Resolve one runtime-attached sketch immediately before consumption."""
sketch = self.sketches.get(sketch_id)
if sketch is None or not isinstance(sketch.get("attachment"), dict):
return
previous_feature_id = self.active_feature_id
if feature_id is not None:
self.active_feature_id = feature_id
try:
resolution = self.resolve(sketch["attachment"])
if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face":
detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved"
raise FeatureExecutionError("sketch_attachment_unresolved", detail)
try:
plane = self.adapter.planar_face_workplane(resolution.record.value)
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error
external_edges: dict[str, Any] = {}
profile = sketch.get("profile") or {}
for entry in profile.get("external_anchors") or ():
anchor_id = entry.get("id") if isinstance(entry, dict) else None
selector = entry.get("selector") if isinstance(entry, dict) else None
if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict):
raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid")
edge_resolution = self.resolve(selector)
if (
edge_resolution.status != "resolved"
or edge_resolution.record is None
or edge_resolution.record.kind != "edge"
):
detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved"
raise FeatureExecutionError("imprint_external_anchor_unresolved", detail)
if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()):
raise FeatureExecutionError(
"imprint_external_anchor_not_support_boundary",
"external anchor edge is not an exact boundary of the attached support face",
)
external_edges[anchor_id] = edge_resolution.record.value
materialized = deepcopy(sketch)
materialized["workplane"] = plane.as_dict()
# IMPRINT face-side disambiguation is expressed in the source
# sketch's oriented plane. A native attached face may have the
# opposite normal, which reverses the physical side of the same
# local curve after its coordinates are mapped to that face.
# This is a session-local frame conversion, not selector or
# geometry fallback data.
source_profile = materialized.get("profile") or {}
if source_profile.get("type") == "planar_imprint":
try:
source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error
orientation = sum(
source_plane.normal[index] * plane.normal[index]
for index in range(3)
)
if orientation < 0.0:
for selection in source_profile.get("selections") or ():
if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}:
selection["face_side"] = -selection["face_side"]
# Preflight intentionally leaves attached sketches local. Resolve now
# so every world-space contour is rebuilt from the proven face frame.
materialized = resolve_profile(materialized)
self.sketches[sketch_id] = materialized
# Keep exact supports only in the live session. They are never CDSL
# geometry, serialized record IDs, or selector fallback evidence.
self.sketch_attachment_faces[sketch_id] = resolution.record.value
if external_edges:
self.sketch_imprint_external_edges[sketch_id] = external_edges
finally:
self.active_feature_id = previous_feature_id
def register_body( def register_body(
self, self,
feature_id: str, feature_id: str,
@@ -114,20 +203,47 @@ class ExecutionSession:
body_members: dict[str, Any] | None = None, body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None, topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None, topology_predecessors: list[TopologyRecord] | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> None: ) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。 # 实体;单体保持 body:{feature}(与历史行为完全一致)。
previous_members = dict(self.body_members)
previous_snapshot_ids = dict(self.body_member_snapshot_ids)
self.body = body self.body = body
self.body_id = f"body:{feature_id}" self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body} self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
# Source-profile anchors are transient construction facts, but unlike
# generic role predecessors they must remain addressable by a later
# selector intent. They never receive a body id, so active selector
# scans cannot mistake them for current model topology.
anchors = list(topology_anchors or ())
for anchor in anchors:
self.topology.register(anchor)
predecessors = [*(topology_predecessors or ()), *anchors]
solids = self.adapter.body_solids(body) solids = self.adapter.body_solids(body)
member_snapshot_ids: dict[str, str] = {}
for member_key, member in self.body_members.items():
matches = [
index for index, solid in enumerate(solids)
if TopologyRegistry._same_topology_value(member, solid)
]
if len(matches) == 1:
member_snapshot_ids[member_key] = (
self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}"
)
member_preservations = [
(previous_snapshot_ids[key], member_snapshot_ids[key])
for key, member in previous_members.items()
if key in previous_snapshot_ids and key in member_snapshot_ids
and TopologyRegistry._same_topology_value(member, self.body_members[key])
]
if len(solids) <= 1: if len(solids) <= 1:
self.topology.replace_body_topology( self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (), additional_predecessors=predecessors, member_preservations=member_preservations,
) )
else: else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
@@ -140,7 +256,7 @@ class ExecutionSession:
] ]
self.topology.replace_body_topologies( self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (), additional_predecessors=predecessors, member_preservations=member_preservations,
) )
self.topology.register(TopologyRecord( self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
@@ -148,6 +264,26 @@ class ExecutionSession:
)) ))
if replay_node is not None: if replay_node is not None:
self.replay_definitions[feature_id] = replay_node self.replay_definitions[feature_id] = replay_node
self.body_member_snapshot_ids = member_snapshot_ids
def register_transient_prism_tool(
self,
feature_id: str,
tool: Any,
*,
topology_delta: TopologyDelta,
topology_anchors: list[TopologyRecord],
) -> list[TopologyRecord]:
"""Keep one direct-prism primary tool as boolean-input evidence only."""
snapshot_id = f"transient:{feature_id}"
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
return list(self.topology.register_transient_snapshot(
feature_id,
snapshot_id,
records,
topology_delta=topology_delta,
anchors=topology_anchors,
))
def register_surface(self, feature_id: str, surface: Any) -> str: def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
@@ -167,6 +303,7 @@ class ExecutionSession:
self.body = None self.body = None
self.body_id = None self.body_id = None
self.body_members = {} self.body_members = {}
self.body_member_snapshot_ids = {}
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict() evidence = resolution.as_dict()
@@ -184,11 +321,15 @@ class ExecutionSession:
binding_feature_id = selector.get("binding_feature_id") binding_feature_id = selector.get("binding_feature_id")
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
failures = [item for item in resolved if item.status != "resolved" or item.record is None] failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)]
if failures: if failures:
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
raise FeatureExecutionError("intersection_selector_component_unresolved", detail) raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
return [item.record for item in resolved if item.record is not None] return [
record
for item in resolved
for record in (item.records or ((item.record,) if item.record is not None else ()))
]
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
components = selector.get("intersection_of") components = selector.get("intersection_of")
@@ -231,6 +372,17 @@ class ExecutionSession:
def resolve(self, selector: dict[str, Any]) -> SelectorResolution: def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None: if selector.get("intersection_of") is not None:
# The session computes vertex intersections directly from resolved
# face components, so enforce the same outer provenance gate that
# TopologyRegistry.resolve applies before any geometry operation.
validation_error = validate_selector_provenance_intent(selector)
if validation_error is not None:
return self._record_selector_resolution(SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=validation_error,
))
return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "") owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
+199 -23
View File
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
_TOLERANCE_MM = 1e-5 _TOLERANCE_MM = 1e-5
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: def _circle(
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} center: list[float],
radius_mm: float,
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: def _line(
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} start: list[float],
end: list[float],
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _point(point: list[float]) -> list[float]: def _point(point: list[float]) -> list[float]:
@@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
cx, cy = float(center[0]), float(center[1]) cx, cy = float(center[0]), float(center[1])
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边 # 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。 # 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], [] source_entity_id = profile.get("source_entity_id")
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
clockwise = bool(segment.get("clockwise", False)) clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
source_entity_id = segment.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
# Region construction needs four arc segments, but this marker records
# that all four came from exactly one logical source circle. It is not
# an edge anchor: callers must rebuild one native circle wire and pass
# final-face identity checks before using it for lineage.
for edge in edges:
edge["logical_circle_source_entity_id"] = source_entity_id
return edges
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]: def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
@@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
def _segment_edges(segment: _Ctx) -> list[_Ctx]: def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type") kind = segment.get("type")
if kind == "line": if kind == "line":
return [_contour_line(segment["start"], segment["end"])] edges = [_contour_line(segment["start"], segment["end"])]
if kind == "arc": elif kind == "arc":
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle": elif kind == "circle":
return _circle_edges(segment) edges = _circle_edges(segment)
if kind == "ellipse": elif kind == "ellipse":
return _ellipse_edges(segment) edges = _ellipse_edges(segment)
if kind == "bspline": elif kind == "bspline":
points = segment.get("points") or [] points = segment.get("points") or []
if len(points) < 2: if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points") raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
@@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents") raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent) output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent) output["end_tangent_mm"] = _point(end_tangent)
return [output] edges = [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") else:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
source_entity_id = segment.get("source_entity_id")
# Only a one-edge construction has the direct, one-to-one source identity
# required by profile-to-prism lineage. Circles expanded to arcs carry a
# separate logical-circle marker, not a source edge identity; the adapter
# may use it only to reconstruct one native circle wire with exact final
# membership proof. Other multi-edge approximations remain unanchored.
if isinstance(source_entity_id, str) and len(edges) == 1:
edges[0]["source_entity_id"] = source_entity_id
return edges
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]: def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
@@ -453,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo
return inside return inside
def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
"""Return whether a sampled contour point lies on one sampled boundary.
Region nesting is topology, not a ray-casting tie-break. A contour that
shares a vertex or an edge with another contour cannot be a hole of it.
The ordinary parity test intentionally leaves that boundary case
unspecified, so keep it out of containment classification explicitly.
"""
if len(loop) < 2:
return False
px, py = point
previous = loop[-1]
for current in loop:
dx, dy = current[0] - previous[0], current[1] - previous[1]
length_squared = dx * dx + dy * dy
if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM:
if math.dist(point, previous) <= _TOLERANCE_MM:
return True
else:
projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared
if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM:
nearest = (previous[0] + projection * dx, previous[1] + projection * dy)
if math.dist(point, nearest) <= _TOLERANCE_MM:
return True
previous = current
return False
def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool:
"""Require every sampled inner boundary point to be strictly inside outer.
This conservative predicate rejects touching and intersecting contours
rather than manufacturing an invalid face with a self-identical or shared
hole. Curved contours are already sampled by ``_sample_loop`` before
this stage, so the decision uses the same region representation as the
existing parity classifier.
"""
return bool(inner) and all(
not _point_on_loop_boundary(point, outer) and _contains(point, outer)
for point in inner
)
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
loops: list[_Ctx] = [] loops: list[_Ctx] = []
entities: list[_Ctx] = [] entities: list[_Ctx] = []
@@ -466,13 +545,30 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
raw_edges: list[_Ctx] = [] raw_edges: list[_Ctx] = []
for segment in contour.get("segments") or []: for segment in contour.get("segments") or []:
if segment.get("type") == "line": if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"])) entities.append(_line(
segment["start"], segment["end"],
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
elif segment.get("type") == "circle": elif segment.get("type") == "circle":
if contour_open: if contour_open:
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment") raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) entities.append(_circle(
segment.get("center") or [0.0, 0.0],
float(segment.get("radius_mm") or 0.0),
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
raw_edges.extend(_segment_edges(segment)) raw_edges.extend(_segment_edges(segment))
if raw_edges: if raw_edges:
# A pure surface extrusion consumes its source wire directly.
# Do not invent the closing edge used by the solid-cut open
# profile contract: it would turn one requested ruled face into
# a different shell. This marker is emitted only by the CADFS
# ToolBodyType.SURFACE lowering contract.
if bool(contour.get("surface_wire")):
if not contour_open or contour.get("role") != "open":
raise ValueError(f"analytic_contours: surface wire {index} must be an open contour")
meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True))
continue
edges = _join(raw_edges, allow_open=contour_open) edges = _join(raw_edges, allow_open=contour_open)
contour_opened = False contour_opened = False
if contour_open: if contour_open:
@@ -494,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
if not loops: if not loops:
return entities, [] return entities, []
for loop in loops: for loop in loops:
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" loop["role"] = "inner" if sum(
_strictly_contains_loop(other["points"], loop["points"])
for other in loops if other is not loop
) % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"] outers = [loop for loop in loops if loop["role"] == "outer"]
regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers] regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers]
for inner in (loop for loop in loops if loop["role"] == "inner"): for inner in (loop for loop in loops if loop["role"] == "inner"):
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] containing = [
outer for outer in outers
if _strictly_contains_loop(outer["points"], inner["points"])
]
if not containing: if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour") raise ValueError("analytic_contours: inner contour has no containing outer contour")
if any(outer.get("open") for outer in containing): if any(outer.get("open") for outer in containing):
@@ -509,6 +611,58 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, [] return entities, []
def _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx:
"""Use the established region classifier once per source profile."""
kind = profile.get("type")
if kind == "analytic_contours":
return deepcopy(profile)
if kind == "circle":
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [{
"type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]),
"radius_mm": profile.get("radius_mm"),
}]}],
}
if kind == "polygon":
vertices = profile.get("vertices") or []
if len(vertices) < 3:
raise ValueError("multi_source_regions: polygon needs at least three vertices")
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])}
for index in range(len(vertices))
]}],
}
raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}")
def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Resolve each qSketchRegion source independently before taking its union.
Flattening all contours would reclassify a nested region from a separate
sketch as a hole. FeatureScript qUnion selects each source region, so its
operands must retain independent outer/hole classification here.
"""
sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or []
if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources):
raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles")
entities: list[_Ctx] = []
regions: list[_Ctx] = []
for child in profiles:
if not isinstance(child, dict):
raise ValueError("multi_source_regions: child profile must be an object")
child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []}
child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta)
if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]:
raise ValueError("multi_source_regions: every child must resolve to closed regions")
entities.extend(child_entities)
regions.extend(child_meta["_regions"])
meta["_regions"] = regions
return entities, []
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split. """Prepare source curves for an exact OCC planar-arrangement split.
@@ -528,8 +682,23 @@ def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ct
if not edges: if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve") raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges}) source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2: external_anchors = profile.get("external_anchors") or []
raise ValueError("planar_imprint: at least two source entities are required") external_ids = {
str(entry.get("id") or "")
for entry in external_anchors
if isinstance(entry, dict)
}
single_external_fragment = (
len(source_entities) == 1
and bool(external_ids)
and all(
isinstance(selection, dict)
and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids
for selection in profile.get("selections") or ()
)
)
if len(source_entities) < 2 and not single_external_fragment:
raise ValueError("planar_imprint: at least two source entities or one external anchor are required")
meta["_imprint_entities"] = source_entities meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or []) meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], [] return [], []
@@ -539,6 +708,7 @@ CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle, "circle": _gen_circle,
"polygon": _gen_polygon, "polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours, "analytic_contours": _gen_analytic_contours,
"multi_source_regions": _gen_multi_source_regions,
"planar_imprint": _gen_planar_imprint, "planar_imprint": _gen_planar_imprint,
} }
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
@@ -546,6 +716,7 @@ SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"}, "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
"multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"},
} }
@@ -566,7 +737,7 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = { meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False, "id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [], "_imprint_entities": [], "_imprint_selections": [], "_surface_wires": [],
} }
entities, contour = generator(profile, meta) entities, contour = generator(profile, meta)
output = deepcopy(sketch) output = deepcopy(sketch)
@@ -584,6 +755,11 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
] ]
if meta["_has_open_contour"]: if meta["_has_open_contour"]:
output["_open_contour"] = True output["_open_contour"] = True
if meta["_surface_wires"]:
output["surface_wires_mm"] = [
_transform_contours(wire, workplane) if workplane else wire
for wire in meta["_surface_wires"]
]
if meta["_imprint_entities"]: if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [ output["imprint_entities_mm"] = [
{ {

Some files were not shown because too many files have changed in this diff Show More