Add model-reviewed CAD experience pipeline
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cad-experience-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Parse STEP inboxes and use the active model plus deterministic evidence checks to distill backend-neutral CAD experience.",
|
||||
"author": {
|
||||
"name": "Local developer"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "CAD Experience",
|
||||
"shortDescription": "Use model review to distill reusable CAD methods.",
|
||||
"longDescription": "CAD Experience maintains a visible STEP input/output staging area, gives the active large model a sanitized semantic review packet, verifies every proposed method against the accumulated corpus, and promotes only repeatedly supported backend-neutral experience.",
|
||||
"developerName": "Local developer",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"STEP inbox batch extraction",
|
||||
"Visible private case JSON",
|
||||
"Batch CAD experience induction",
|
||||
"Large-model semantic review",
|
||||
"Deterministic proposal evidence verification",
|
||||
"Generalized experience queries",
|
||||
"Instance-data leakage audits"
|
||||
],
|
||||
"defaultPrompt": "Parse the STEP input folder, distill the output JSON, and report promoted generalized CAD experience without exposing source-part geometry."
|
||||
}
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$PLUGIN_ROOT/.." && pwd)"
|
||||
PYTHON_BIN="${CAD_EXPERIENCE_PYTHON:-}"
|
||||
|
||||
if [[ -z "$PYTHON_BIN" && -x "$WORKSPACE_ROOT/text-to-cad/.venv/bin/python" ]]; then
|
||||
PYTHON_BIN="$WORKSPACE_ROOT/text-to-cad/.venv/bin/python"
|
||||
fi
|
||||
if [[ -z "$PYTHON_BIN" ]]; then
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" "$PLUGIN_ROOT/skills/cad-experience-builder/scripts/cad_experience.py" "$@"
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: cad-experience-builder
|
||||
description: Batch-extract STEP files into private JSON, use the active large model to semantically distill transferable CAD methods from a sanitized batch, and deterministically verify and publish a backend-neutral experience library. Use when distilling STEP/STP corpora, reviewing candidate methods, inducing feature motifs and constraints, auditing instance-data leakage, or preparing promoted experience for cad-router and CAD backends.
|
||||
---
|
||||
|
||||
# CAD Experience Builder
|
||||
|
||||
Induce methods, not copied answers. Keep source-part evidence outside the
|
||||
published library and require repeated support before promoting any experience.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Put source `.step` or `.stp` files in `parser/input/`.
|
||||
2. Parse every new, content-unique STEP into visible private JSON under
|
||||
`parser/output/`:
|
||||
|
||||
```bash
|
||||
../../scripts/cad-experience extract-folder
|
||||
```
|
||||
|
||||
3. Prepare a sanitized semantic batch:
|
||||
|
||||
```bash
|
||||
../../scripts/cad-experience prepare
|
||||
```
|
||||
|
||||
4. Read `work/review/semantic-batch.json` completely. Act as the semantic
|
||||
distiller: compare families, features, relations, prior vocabulary, failure
|
||||
rules, and validation targets; then author
|
||||
`work/review/experience-draft.json` using
|
||||
`references/llm-review-contract.md`.
|
||||
|
||||
Propose methods and invariants, not raw co-occurrence pairs. Do not infer
|
||||
operation order, manufacturing intent, or failure causes that final B-Rep
|
||||
evidence cannot support.
|
||||
|
||||
5. Publish only after the model draft exists:
|
||||
|
||||
```bash
|
||||
../../scripts/cad-experience publish \
|
||||
--draft ../../work/review/experience-draft.json
|
||||
```
|
||||
|
||||
The publisher recomputes support from the private corpus, rejects numeric
|
||||
instance answers, keeps weak proposals as candidates, and marks the library
|
||||
router-consumable only after this verification.
|
||||
|
||||
6. Audit the public library. Treat any failure as a hard publishing failure:
|
||||
|
||||
```bash
|
||||
python scripts/cad_experience.py audit \
|
||||
/path/to/cad-experience-library/library.json
|
||||
```
|
||||
|
||||
7. Query only the published library for creation, modification, or rebuilding:
|
||||
|
||||
```bash
|
||||
python scripts/cad_experience.py query \
|
||||
/path/to/cad-experience-library/library.json \
|
||||
--family flanged_hub_adapter \
|
||||
--features base_flange,hollow_sleeve,counterbore
|
||||
```
|
||||
|
||||
8. Pass the query result, never `parser/input`, `parser/output`, or a case JSON,
|
||||
to `$cad-router` or a backend.
|
||||
|
||||
Direct `distill` without an LLM-authored draft is intentionally blocked. The
|
||||
active Codex model supplies semantic judgment; no separate web API key is
|
||||
required.
|
||||
|
||||
## STEP extraction boundary
|
||||
|
||||
- The extractor reads analytic B-Rep surfaces, bounding boxes, topology counts,
|
||||
axes, radii, and locations into a private evidence record.
|
||||
- It derives only semantic candidates such as rotational stacks, axis
|
||||
relationships, transitions, and repeated-hole motifs.
|
||||
- Exact source paths, hashes, dimensions, coordinates, and face references are
|
||||
permitted in private case JSON and forbidden in the published library.
|
||||
- Keep input/output as a human-visible staging area and induction corpus. Never
|
||||
use it directly during creation, modification, or reconstruction.
|
||||
- A semantic candidate is not a trusted design method until repeated,
|
||||
independently sourced cases satisfy the promotion policy.
|
||||
|
||||
## Promotion rules
|
||||
|
||||
- One case is evidence, not experience.
|
||||
- Publish generalized, dimension-free observations below threshold as visible
|
||||
`candidate_experiences`, but never expose them to generation consumers.
|
||||
- Require `min_support` independent cases and `min_confidence` before promotion.
|
||||
- Promote feature compositions, semantic constraints, dimensionless
|
||||
distributions, validation checks, and failure-repair methods.
|
||||
- Reject absolute dimensions, coordinates, face references, source paths,
|
||||
source hashes, and full parameter dictionaries from published experience.
|
||||
- Keep generated examples out of the trusted corpus until independently
|
||||
validated.
|
||||
- Evaluate generalization on held-out cases that were not used for induction.
|
||||
|
||||
## Backend contract
|
||||
|
||||
The library is backend-neutral. A consumer receives:
|
||||
|
||||
- `feature_motif`: reusable compositions of feature types;
|
||||
- `constraint`: invariant semantic relationships;
|
||||
- `design_rule`: conditional modeling guidance;
|
||||
- `dimensionless_distribution`: multi-case ratio ranges;
|
||||
- `validation_rule`: checks independent of one part's coordinates;
|
||||
- `failure_repair`: reusable detection and repair methods.
|
||||
|
||||
build123d, SimpleCADAPI, or another backend translates the same queried
|
||||
experience into its own operations. Experience must never contain executable
|
||||
teacher geometry.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/case-input-contract.md` before producing batch input.
|
||||
- Read `references/experience-library-schema.md` before extending the output
|
||||
schema or adding a consumer.
|
||||
- Read `references/llm-review-contract.md` before authoring or revising a
|
||||
semantic proposal draft.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "CAD Experience Builder"
|
||||
short_description: "用大模型语义复核与证据门禁蒸馏可迁移的 CAD 通用经验"
|
||||
default_prompt: "Use $cad-experience-builder to prepare sanitized STEP evidence, perform model-led semantic induction, publish only evidence-verified generalized CAD experience, and report candidate versus promoted results."
|
||||
@@ -0,0 +1,54 @@
|
||||
# Case JSON input contract
|
||||
|
||||
Case JSON is private evidence used only during induction. It may contain exact
|
||||
parameters, but the inducer never copies those fields into published experience.
|
||||
Store source STEP files in `cad-experience-plugin/parser/input` and generated
|
||||
case JSON in `cad-experience-plugin/parser/output`.
|
||||
|
||||
Do not pass either folder or a case JSON to cad-router or a CAD backend.
|
||||
Creation, modification, and reconstruction may consume only the promoted public
|
||||
experience library.
|
||||
|
||||
Supported fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"provenance": {"source_sha256": "..."},
|
||||
"design_ir": {
|
||||
"part_family": "flanged_hub_adapter",
|
||||
"features": [{"type": "base_flange"}, {"type": "hollow_sleeve"}],
|
||||
"constraints": [{"id": "coaxial_stack", "type": "coaxial"}],
|
||||
"normalized_observations": [
|
||||
{
|
||||
"name": "sleeve_od_to_base_od",
|
||||
"value": 0.35,
|
||||
"numerator_role": "sleeve_outer_diameter",
|
||||
"denominator_role": "base_outer_diameter"
|
||||
}
|
||||
]
|
||||
},
|
||||
"experience": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "bound_counterbore_to_host",
|
||||
"scope": ["counterbore"],
|
||||
"statement": "Terminate a counterbore at its host boundary.",
|
||||
"repair": "Bind the cutter extent to the host feature."
|
||||
}
|
||||
],
|
||||
"validation_targets": [
|
||||
{
|
||||
"id": "counterbore_host_boundary",
|
||||
"check": "A counterbore does not cross an unrelated adjacent feature."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules and checks containing numeric literals are rejected because they are
|
||||
likely instance answers. Ratios are accepted only through
|
||||
`normalized_observations`, with an explicit numerator and denominator role.
|
||||
|
||||
Legacy distilled JSON can be used when it contains the fields above. Exact
|
||||
`parameters`, `evidence`, centers, axes, and surface references are ignored.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Published experience library
|
||||
|
||||
The published library uses schema version `2.0` and contains only promoted,
|
||||
generalized experience:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"library_kind": "generalized_cad_experience",
|
||||
"induction_mode": "llm_proposals_with_deterministic_evidence_verification",
|
||||
"policy": {
|
||||
"instance_parameters_allowed": false,
|
||||
"absolute_coordinates_allowed": false,
|
||||
"llm_semantic_review_required": true,
|
||||
"draft_evidence_verified": true,
|
||||
"router_consumable": true,
|
||||
"minimum_support": 20
|
||||
},
|
||||
"corpus_summary": {
|
||||
"accepted_case_count": 1000,
|
||||
"duplicate_case_count": 12
|
||||
},
|
||||
"experience_summary": {
|
||||
"llm_proposal_count": 24,
|
||||
"promoted_experience_count": 18,
|
||||
"candidate_experience_count": 6,
|
||||
"by_kind": [{"kind": "feature_motif", "count": 7}],
|
||||
"by_scope": [{"scope": "flanged_hub_adapter", "count": 5}]
|
||||
},
|
||||
"experiences": [
|
||||
{
|
||||
"id": "motif.base_flange+hollow_sleeve",
|
||||
"kind": "feature_motif",
|
||||
"scope": ["flanged_hub_adapter"],
|
||||
"when": {"features": ["base_flange", "hollow_sleeve"]},
|
||||
"guidance": "Treat these feature roles as a reusable composition.",
|
||||
"support": 243,
|
||||
"confidence": 0.91
|
||||
}
|
||||
],
|
||||
"candidate_experiences": [
|
||||
{
|
||||
"id": "motif.flanged_hub_adapter.base_flange+hollow_sleeve",
|
||||
"kind": "feature_motif",
|
||||
"scope": ["flanged_hub_adapter"],
|
||||
"when": {"features": ["base_flange", "hollow_sleeve"]},
|
||||
"support": 7,
|
||||
"required_support": 20,
|
||||
"remaining_support": 13,
|
||||
"promotion_state": "candidate",
|
||||
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Allowed numeric values describe evidence strength or dimensionless
|
||||
distributions: support counts, confidence, frequency, percentiles, and sample
|
||||
counts. Absolute lengths, diameters, heights, radii, angles tied to a single
|
||||
case, positions, and coordinates are forbidden.
|
||||
|
||||
Consumers query this library by requested family and feature vocabulary. They
|
||||
must not receive the induction corpus, private case JSON, or
|
||||
`candidate_experiences`. Only `experiences` is executable guidance.
|
||||
@@ -0,0 +1,45 @@
|
||||
# LLM semantic review contract
|
||||
|
||||
Read the sanitized `work/review/semantic-batch.json`, not raw STEP geometry.
|
||||
Maintain stable proposal identifiers when an existing candidate expresses the
|
||||
same method.
|
||||
|
||||
Write `work/review/experience-draft.json` with this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"draft_kind": "llm_generalized_experience_proposals",
|
||||
"proposals": [
|
||||
{
|
||||
"id": "constraint.part_family.shared_axis",
|
||||
"kind": "constraint",
|
||||
"scope": ["part_family"],
|
||||
"evidence_query": {
|
||||
"required_features": ["rotational_body", "central_passage"],
|
||||
"required_relations": ["dominant_axis_alignment"]
|
||||
},
|
||||
"guidance": "Preserve a shared axis across the participating feature roles.",
|
||||
"semantic_rationale": "The relationship remains meaningful when individual dimensions change.",
|
||||
"check": "Confirm that participating features retain their intended common axis."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Allowed kinds are `feature_motif`, `constraint`, `design_rule`,
|
||||
`validation_rule`, and `failure_repair`.
|
||||
|
||||
Follow these rules:
|
||||
|
||||
- Require an evidence query composed only of semantic feature and relation
|
||||
tokens present in the sanitized batch.
|
||||
- Use scopes to prevent unrelated part families from supporting a proposal.
|
||||
- Explain why a proposal transfers across dimensions and variants.
|
||||
- Avoid dimensions, coordinates, source identifiers, face references, and
|
||||
numeric literals in prose.
|
||||
- Do not infer build order or causal failure mechanisms from final geometry.
|
||||
- Prefer a small set of meaningful methods over every possible feature pair.
|
||||
- Preserve earlier proposal IDs when evidence still supports the same meaning.
|
||||
- Let the publisher calculate support and confidence; never claim them in the
|
||||
draft.
|
||||
+1176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a private, backend-neutral case record from an analytic STEP model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _rounded(value: float) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def _component(value: Any, name: str) -> float:
|
||||
component = getattr(value, name)
|
||||
return float(component() if callable(component) else component)
|
||||
|
||||
|
||||
def _point(value: Any) -> list[float]:
|
||||
return [_rounded(_component(value, name)) for name in ("X", "Y", "Z")]
|
||||
|
||||
|
||||
def _direction(value: Any) -> list[float]:
|
||||
return _point(value)
|
||||
|
||||
|
||||
def _canonical_axis(direction: list[float]) -> str:
|
||||
index = max(range(3), key=lambda item: abs(direction[item]))
|
||||
return ("x", "y", "z")[index]
|
||||
|
||||
|
||||
def _radial_offset(location: list[float], center: list[float], axis: str) -> float:
|
||||
axes = {"x": (1, 2), "y": (0, 2), "z": (0, 1)}
|
||||
first, second = axes[axis]
|
||||
return math.hypot(
|
||||
location[first] - center[first],
|
||||
location[second] - center[second],
|
||||
)
|
||||
|
||||
|
||||
def _surface_record(face: Any, index: int) -> dict[str, Any]:
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import (
|
||||
GeomAbs_Cone,
|
||||
GeomAbs_Cylinder,
|
||||
GeomAbs_Plane,
|
||||
GeomAbs_Sphere,
|
||||
GeomAbs_Torus,
|
||||
)
|
||||
|
||||
adaptor = BRepAdaptor_Surface(face.wrapped)
|
||||
kind = adaptor.GetType()
|
||||
record: dict[str, Any] = {
|
||||
"surface_id": f"face_{index}",
|
||||
"area": _rounded(face.area),
|
||||
"center": _point(face.center()),
|
||||
}
|
||||
if kind == GeomAbs_Plane:
|
||||
plane = adaptor.Plane()
|
||||
record.update(
|
||||
{
|
||||
"type": "plane",
|
||||
"location": _point(plane.Location()),
|
||||
"axis": _direction(plane.Axis().Direction()),
|
||||
}
|
||||
)
|
||||
elif kind == GeomAbs_Cylinder:
|
||||
cylinder = adaptor.Cylinder()
|
||||
record.update(
|
||||
{
|
||||
"type": "cylinder",
|
||||
"location": _point(cylinder.Location()),
|
||||
"axis": _direction(cylinder.Axis().Direction()),
|
||||
"radius": _rounded(cylinder.Radius()),
|
||||
}
|
||||
)
|
||||
elif kind == GeomAbs_Cone:
|
||||
cone = adaptor.Cone()
|
||||
record.update(
|
||||
{
|
||||
"type": "cone",
|
||||
"location": _point(cone.Location()),
|
||||
"axis": _direction(cone.Axis().Direction()),
|
||||
"reference_radius": _rounded(cone.RefRadius()),
|
||||
"semi_angle_radians": _rounded(cone.SemiAngle()),
|
||||
}
|
||||
)
|
||||
elif kind == GeomAbs_Sphere:
|
||||
sphere = adaptor.Sphere()
|
||||
record.update(
|
||||
{
|
||||
"type": "sphere",
|
||||
"location": _point(sphere.Location()),
|
||||
"radius": _rounded(sphere.Radius()),
|
||||
}
|
||||
)
|
||||
elif kind == GeomAbs_Torus:
|
||||
torus = adaptor.Torus()
|
||||
record.update(
|
||||
{
|
||||
"type": "torus",
|
||||
"location": _point(torus.Location()),
|
||||
"axis": _direction(torus.Axis().Direction()),
|
||||
"major_radius": _rounded(torus.MajorRadius()),
|
||||
"minor_radius": _rounded(torus.MinorRadius()),
|
||||
}
|
||||
)
|
||||
else:
|
||||
record["type"] = "other"
|
||||
return record
|
||||
|
||||
|
||||
def _semantic_summary(
|
||||
surfaces: list[dict[str, Any]], bbox_center: list[float], bbox_size: list[float]
|
||||
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
|
||||
cylinders = [item for item in surfaces if item["type"] == "cylinder"]
|
||||
cones = [item for item in surfaces if item["type"] == "cone"]
|
||||
axis_votes: Counter[str] = Counter()
|
||||
for surface in cylinders + cones:
|
||||
axis_votes[_canonical_axis(surface["axis"])] += max(surface["area"], 1.0)
|
||||
dominant_axis = axis_votes.most_common(1)[0][0] if axis_votes else "z"
|
||||
scale = max(bbox_size) if bbox_size else 1.0
|
||||
coaxial_tolerance = max(scale * 0.005, 1e-4)
|
||||
|
||||
parallel_cylinders: list[dict[str, Any]] = []
|
||||
coaxial_cylinders: list[dict[str, Any]] = []
|
||||
off_axis_cylinders: list[dict[str, Any]] = []
|
||||
for surface in cylinders:
|
||||
if _canonical_axis(surface["axis"]) != dominant_axis:
|
||||
continue
|
||||
enriched = dict(surface)
|
||||
enriched["radial_offset"] = _rounded(
|
||||
_radial_offset(surface["location"], bbox_center, dominant_axis)
|
||||
)
|
||||
parallel_cylinders.append(enriched)
|
||||
if enriched["radial_offset"] <= coaxial_tolerance:
|
||||
coaxial_cylinders.append(enriched)
|
||||
else:
|
||||
off_axis_cylinders.append(enriched)
|
||||
|
||||
distinct_coaxial_radii = {
|
||||
round(surface["radius"], 4) for surface in coaxial_cylinders
|
||||
}
|
||||
repeated_groups: dict[float, list[dict[str, Any]]] = defaultdict(list)
|
||||
for surface in off_axis_cylinders:
|
||||
repeated_groups[round(surface["radius"], 4)].append(surface)
|
||||
repeated_group_sizes = sorted(
|
||||
(len(rows) for rows in repeated_groups.values()), reverse=True
|
||||
)
|
||||
parallel_cones = [
|
||||
item for item in cones if _canonical_axis(item["axis"]) == dominant_axis
|
||||
]
|
||||
has_rotational_stack = len(distinct_coaxial_radii) >= 2
|
||||
has_repeated_axial_holes = bool(repeated_group_sizes and repeated_group_sizes[0] >= 3)
|
||||
has_multi_axis_passages = any(
|
||||
_canonical_axis(item["axis"]) != dominant_axis for item in cylinders
|
||||
)
|
||||
|
||||
features: list[dict[str, Any]] = []
|
||||
if has_rotational_stack:
|
||||
features.extend(
|
||||
[
|
||||
{"type": "rotational_body"},
|
||||
{"type": "coaxial_cylindrical_stack"},
|
||||
]
|
||||
)
|
||||
if len(distinct_coaxial_radii) >= 3:
|
||||
features.append({"type": "central_passage_candidate"})
|
||||
if parallel_cones:
|
||||
features.append({"type": "conical_transition"})
|
||||
if has_repeated_axial_holes:
|
||||
features.append({"type": "repeated_axial_hole_pattern"})
|
||||
if has_multi_axis_passages:
|
||||
features.append({"type": "multi_axis_passage"})
|
||||
|
||||
constraints: list[dict[str, Any]] = []
|
||||
if has_rotational_stack:
|
||||
constraints.extend(
|
||||
[
|
||||
{"id": "dominant_axis_alignment", "type": "axis_alignment"},
|
||||
{"id": "coaxial_stack", "type": "coaxial"},
|
||||
]
|
||||
)
|
||||
if has_repeated_axial_holes:
|
||||
constraints.append(
|
||||
{"id": "repeated_radius_group", "type": "repeated_feature"}
|
||||
)
|
||||
if parallel_cones:
|
||||
constraints.append(
|
||||
{"id": "transition_axis_continuity", "type": "axis_alignment"}
|
||||
)
|
||||
|
||||
if has_rotational_stack and parallel_cones and has_repeated_axial_holes:
|
||||
family = "flanged_hub_adapter_candidate"
|
||||
elif has_rotational_stack:
|
||||
family = "rotational_part_candidate"
|
||||
elif has_repeated_axial_holes:
|
||||
family = "patterned_prismatic_part_candidate"
|
||||
else:
|
||||
family = "unclassified_part"
|
||||
|
||||
summary = {
|
||||
"dominant_axis": dominant_axis,
|
||||
"coaxial_cylinder_surface_count": len(coaxial_cylinders),
|
||||
"distinct_coaxial_radius_count": len(distinct_coaxial_radii),
|
||||
"off_axis_parallel_cylinder_surface_count": len(off_axis_cylinders),
|
||||
"largest_repeated_radius_group": repeated_group_sizes[0]
|
||||
if repeated_group_sizes
|
||||
else 0,
|
||||
"parallel_cone_surface_count": len(parallel_cones),
|
||||
"has_multi_axis_passages": has_multi_axis_passages,
|
||||
}
|
||||
return family, features, constraints, summary
|
||||
|
||||
|
||||
def extract_step_case(source: Path) -> dict[str, Any]:
|
||||
from build123d import import_step
|
||||
|
||||
resolved = source.expanduser().resolve()
|
||||
source_bytes = resolved.read_bytes()
|
||||
shape = import_step(str(resolved))
|
||||
bbox = shape.bounding_box()
|
||||
bbox_min = _point(bbox.min)
|
||||
bbox_max = _point(bbox.max)
|
||||
bbox_size = _point(bbox.size)
|
||||
bbox_center = [
|
||||
_rounded((lower + upper) / 2.0)
|
||||
for lower, upper in zip(bbox_min, bbox_max)
|
||||
]
|
||||
surfaces = [
|
||||
_surface_record(face, index) for index, face in enumerate(shape.faces())
|
||||
]
|
||||
surface_counts = Counter(surface["type"] for surface in surfaces)
|
||||
family, features, constraints, semantic_summary = _semantic_summary(
|
||||
surfaces, bbox_center, bbox_size
|
||||
)
|
||||
digest = hashlib.sha256(source_bytes).hexdigest()
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"case_kind": "private_step_evidence",
|
||||
"case_id": digest,
|
||||
"provenance": {
|
||||
"source_path": str(resolved),
|
||||
"source_sha256": digest,
|
||||
"source_format": "step",
|
||||
},
|
||||
"geometry_evidence": {
|
||||
"valid": bool(shape.is_valid),
|
||||
"solid_count": len(shape.solids()),
|
||||
"face_count": len(shape.faces()),
|
||||
"volume": _rounded(shape.volume),
|
||||
"bounding_box": {
|
||||
"min": bbox_min,
|
||||
"max": bbox_max,
|
||||
"size": bbox_size,
|
||||
"center": bbox_center,
|
||||
},
|
||||
"analytic_surface_counts": dict(sorted(surface_counts.items())),
|
||||
"analytic_surfaces": surfaces,
|
||||
},
|
||||
"design_ir": {
|
||||
"part_family": family,
|
||||
"features": features,
|
||||
"constraints": constraints,
|
||||
"normalized_observations": [],
|
||||
"semantic_summary": semantic_summary,
|
||||
},
|
||||
"experience": {
|
||||
"rules": [],
|
||||
"validation_targets": [],
|
||||
"promotion_state": "evidence_only",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract a private case JSON from a STEP file."
|
||||
)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
write_json(args.output, extract_step_case(args.source))
|
||||
print(args.output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).parents[1]
|
||||
/ "skills"
|
||||
/ "cad-experience-builder"
|
||||
/ "scripts"
|
||||
/ "cad_experience.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("cad_experience", SCRIPT)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
STEP_SCRIPT = (
|
||||
Path(__file__).parents[1]
|
||||
/ "skills"
|
||||
/ "cad-experience-builder"
|
||||
/ "scripts"
|
||||
/ "step_to_case.py"
|
||||
)
|
||||
STEP_SPEC = importlib.util.spec_from_file_location("step_to_case", STEP_SCRIPT)
|
||||
STEP_MODULE = importlib.util.module_from_spec(STEP_SPEC)
|
||||
assert STEP_SPEC.loader
|
||||
STEP_SPEC.loader.exec_module(STEP_MODULE)
|
||||
|
||||
|
||||
def make_case(index: int) -> dict:
|
||||
return {
|
||||
"provenance": {"source_sha256": f"case_{index}"},
|
||||
"design_ir": {
|
||||
"part_family": "flanged_hub_adapter",
|
||||
"features": [
|
||||
{"type": "base_flange", "center": [91.0, 52.0]},
|
||||
{"type": "hollow_sleeve", "surface_ids": ["face_1"]},
|
||||
{"type": "counterbore"},
|
||||
],
|
||||
"constraints": [{"id": "coaxial_stack", "type": "coaxial"}],
|
||||
"parameters": {
|
||||
"base_outer_diameter": 91.0,
|
||||
"counterbore_spacing": 52.0,
|
||||
},
|
||||
"normalized_observations": [
|
||||
{
|
||||
"name": "sleeve_od_to_base_od",
|
||||
"value": 0.3 + index * 0.001,
|
||||
"numerator_role": "sleeve_outer_diameter",
|
||||
"denominator_role": "base_outer_diameter",
|
||||
}
|
||||
],
|
||||
},
|
||||
"experience": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "bound_counterbore_to_host",
|
||||
"scope": ["flanged_hub_adapter"],
|
||||
"statement": "Terminate a counterbore at its host boundary.",
|
||||
"repair": "Bind the cutter extent to the host feature.",
|
||||
}
|
||||
],
|
||||
"validation_targets": [
|
||||
{
|
||||
"id": "host_boundary",
|
||||
"check": "A counterbore does not cross an unrelated adjacent feature.",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CadExperienceTest(unittest.TestCase):
|
||||
def reviewed_proposal(self) -> dict:
|
||||
return {
|
||||
"id": "constraint.flanged_hub_adapter.shared_axis",
|
||||
"kind": "constraint",
|
||||
"scope": ["flanged_hub_adapter"],
|
||||
"evidence_query": {
|
||||
"required_features": ["base_flange", "hollow_sleeve"],
|
||||
"required_relations": ["coaxial_stack"],
|
||||
},
|
||||
"guidance": "Preserve a shared axis across participating feature roles.",
|
||||
"semantic_rationale": "The relationship transfers across dimensional variants.",
|
||||
}
|
||||
|
||||
def test_semantic_summary_derives_roles_without_copying_dimensions(self):
|
||||
surfaces = [
|
||||
{
|
||||
"type": "cylinder",
|
||||
"axis": [0.0, 1.0, 0.0],
|
||||
"location": [0.0, 0.0, 0.0],
|
||||
"radius": radius,
|
||||
"area": 100.0,
|
||||
}
|
||||
for radius in (5.0, 10.0, 20.0)
|
||||
]
|
||||
surfaces.extend(
|
||||
{
|
||||
"type": "cylinder",
|
||||
"axis": [0.0, 1.0, 0.0],
|
||||
"location": [offset, 0.0, 0.0],
|
||||
"radius": 2.0,
|
||||
"area": 20.0,
|
||||
}
|
||||
for offset in (-15.0, 15.0, 16.0)
|
||||
)
|
||||
surfaces.append(
|
||||
{
|
||||
"type": "cone",
|
||||
"axis": [0.0, 1.0, 0.0],
|
||||
"location": [0.0, 0.0, 0.0],
|
||||
"reference_radius": 10.0,
|
||||
"semi_angle_radians": 0.5,
|
||||
"area": 50.0,
|
||||
}
|
||||
)
|
||||
family, features, constraints, summary = STEP_MODULE._semantic_summary(
|
||||
surfaces, [0.0, 0.0, 0.0], [40.0, 20.0, 40.0]
|
||||
)
|
||||
self.assertEqual("flanged_hub_adapter_candidate", family)
|
||||
self.assertIn(
|
||||
"coaxial_cylindrical_stack", {item["type"] for item in features}
|
||||
)
|
||||
self.assertIn("coaxial_stack", {item["id"] for item in constraints})
|
||||
self.assertEqual("y", summary["dominant_axis"])
|
||||
|
||||
def test_single_case_never_promotes(self):
|
||||
case = MODULE.normalize_case(make_case(1))
|
||||
library = MODULE.induce(
|
||||
[case],
|
||||
2,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
self.assertEqual(library["status"], "collecting_evidence")
|
||||
self.assertEqual(library["experiences"], [])
|
||||
self.assertTrue(library["candidate_experiences"])
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["promotion_state"] == "candidate"
|
||||
for item in library["candidate_experiences"]
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["consumer_policy"]
|
||||
== "visible_for_review_but_not_available_to_cad_router"
|
||||
for item in library["candidate_experiences"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_batch_promotes_methods_without_instance_values(self):
|
||||
cases = [MODULE.normalize_case(make_case(index)) for index in range(20)]
|
||||
library = MODULE.induce(
|
||||
cases,
|
||||
20,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
self.assertEqual(MODULE.audit_library(library), [])
|
||||
rendered = json.dumps(library)
|
||||
self.assertNotIn('"parameters"', rendered)
|
||||
self.assertNotIn("91.0", rendered)
|
||||
self.assertNotIn("52.0", rendered)
|
||||
self.assertNotIn('"center"', rendered)
|
||||
ids = {item["id"] for item in library["experiences"]}
|
||||
self.assertIn("rule.bound_counterbore_to_host", ids)
|
||||
self.assertIn("constraint.flanged_hub_adapter.coaxial_stack", ids)
|
||||
self.assertIn("distribution.sleeve_od_to_base_od", ids)
|
||||
|
||||
def test_family_support_is_not_diluted_by_unrelated_families(self):
|
||||
target = [MODULE.normalize_case(make_case(index)) for index in range(20)]
|
||||
unrelated = []
|
||||
for index in range(100):
|
||||
payload = make_case(index + 1000)
|
||||
payload["design_ir"]["part_family"] = "gear"
|
||||
payload["design_ir"]["features"] = [
|
||||
{"type": "gear_blank"},
|
||||
{"type": "gear_teeth"},
|
||||
]
|
||||
unrelated.append(MODULE.normalize_case(payload))
|
||||
library = MODULE.induce(
|
||||
target + unrelated,
|
||||
20,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
ids = {item["id"] for item in library["experiences"]}
|
||||
self.assertIn(
|
||||
"motif.flanged_hub_adapter.base_flange+hollow_sleeve", ids
|
||||
)
|
||||
|
||||
def test_query_returns_backend_neutral_context(self):
|
||||
cases = [MODULE.normalize_case(make_case(index)) for index in range(20)]
|
||||
library = MODULE.induce(
|
||||
cases,
|
||||
20,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
result = MODULE.query_library(
|
||||
library,
|
||||
"flanged_hub_adapter",
|
||||
{"base_flange", "hollow_sleeve", "counterbore"},
|
||||
)
|
||||
self.assertTrue(result["experiences"])
|
||||
self.assertFalse(result["policy"]["contains_instance_parameters"])
|
||||
|
||||
def test_llm_proposal_is_candidate_until_evidence_threshold(self):
|
||||
proposal = self.reviewed_proposal()
|
||||
one_case = MODULE.normalize_case(make_case(1))
|
||||
candidate_library = MODULE.build_reviewed_library(
|
||||
[one_case],
|
||||
[proposal],
|
||||
20,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
self.assertEqual([], candidate_library["experiences"])
|
||||
self.assertEqual(1, len(candidate_library["candidate_experiences"]))
|
||||
self.assertTrue(candidate_library["policy"]["router_consumable"])
|
||||
|
||||
cases = [MODULE.normalize_case(make_case(index)) for index in range(20)]
|
||||
promoted_library = MODULE.build_reviewed_library(
|
||||
cases,
|
||||
[proposal],
|
||||
20,
|
||||
0.8,
|
||||
{"duplicate_case_count": 0, "rejected_case_count": 0},
|
||||
)
|
||||
self.assertEqual(1, len(promoted_library["experiences"]))
|
||||
self.assertEqual([], promoted_library["candidate_experiences"])
|
||||
|
||||
def test_numeric_instance_answer_is_rejected_from_llm_draft(self):
|
||||
proposal = self.reviewed_proposal()
|
||||
proposal["guidance"] = "Always use a diameter of 91 millimeters."
|
||||
with self.assertRaisesRegex(ValueError, "instance numbers"):
|
||||
MODULE.normalize_proposals(
|
||||
{
|
||||
"draft_kind": "llm_generalized_experience_proposals",
|
||||
"proposals": [proposal],
|
||||
}
|
||||
)
|
||||
|
||||
def test_direct_distill_without_model_draft_is_blocked(self):
|
||||
with self.assertRaisesRegex(SystemExit, "statistical distillation is disabled"):
|
||||
MODULE.main(["distill"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user