feat: unify CAD workflows on DesignIR 3.0

This commit is contained in:
Jerry
2026-07-28 14:41:26 +08:00
parent 6e33bc0e11
commit b5738e9109
36 changed files with 9690 additions and 1305 deletions
@@ -63,6 +63,17 @@ 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.
The library also supports:
- `parameter_naming`: canonical editable names inferred from repeated B-Rep
evidence;
- `edit_strategy`: expected-change and non-target-preservation behavior for a
semantic parameter.
Evidence support alone never makes a method router-consumable. Promotion
requires a positive held-out A/B result on independent source cases and full
edit-contract success for that method.
## Reconstruction grammar
`reconstruction_grammar` is the executable bridge between generalized
@@ -30,7 +30,17 @@ Write `runs/review/experience-draft.json` with this shape:
Allowed kinds are `feature_motif`, `constraint`, `design_rule`,
`validation_rule`, `failure_repair`, `dimensionless_distribution`, and
`reconstruction_grammar`.
`reconstruction_grammar`, plus:
- `parameter_naming`: maps repeated final-B-Rep evidence to a canonical
editable name such as `plate_thickness`, `hole_spacing`,
`bolt_circle_diameter`, or `rib_count`;
- `edit_strategy`: defines the expected target change and preservation contract
for a named semantic parameter.
These proposal kinds use `evidence_query.required_parameters`. A name is a
reconstruction-system convention, never a claim that the STEP contained the
source parameter.
Distribution proposals also provide `observation_name`; the publisher computes
the distribution from private evidence and never accepts values from the draft.
@@ -86,6 +96,9 @@ Follow these rules:
- Avoid dimensions, coordinates, source identifiers, face references, and
numeric literals in prose.
- Do not infer build order or causal failure mechanisms from final geometry.
- Do not present inferred parameter names as recovered source parameters.
- Prefer specific editable names over generic span roles only when repeated
geometric evidence supports the distinction.
- Treat `canonical_stages` as a recommended reconstruction order, never as a
claim about the source CAD history.
- For reconstruction grammars, retain only roles and stages repeated by the
@@ -65,7 +65,17 @@ ALLOWED_NUMERIC_KEYS = {
"remaining_support",
"required_confidence",
"llm_proposal_count",
"independent_case_count",
"edit_contract_pass_rate",
"baseline_score_delta",
}
ALLOWED_NUMERIC_SUFFIXES = (
"_count",
"_rate",
"_ratio",
"_delta",
"_support",
)
def utc_now() -> str:
@@ -209,7 +219,12 @@ def normalize_case(payload: dict[str, Any]) -> dict[str, Any]:
experience = payload.get("experience") if isinstance(payload.get("experience"), dict) else {}
provenance = payload.get("provenance") if isinstance(payload.get("provenance"), dict) else {}
supplied_id = provenance.get("source_sha256") or payload.get("case_id")
case_id = token(supplied_id, content_hash(payload))
raw_id = str(supplied_id or "").strip().lower()
case_id = (
raw_id
if re.fullmatch(r"[a-f0-9]{64}|[a-z][a-z0-9_.+-]*", raw_id)
else content_hash(payload)
)
family = token(design_ir.get("part_family") or payload.get("part_family"))
if family.endswith("_candidate"):
family = family.removesuffix("_candidate")
@@ -287,14 +302,67 @@ def normalize_case(payload: dict[str, Any]) -> dict[str, Any]:
}
)
semantic_parameters: list[dict[str, Any]] = []
raw_parameters = design_ir.get("semantic_parameter_candidates", [])
if isinstance(raw_parameters, list):
for raw in raw_parameters:
if not isinstance(raw, dict):
continue
name = token(raw.get("name"))
if name == "unknown":
continue
edit_contract = (
raw.get("edit_contract")
if isinstance(raw.get("edit_contract"), dict)
else {}
)
confidence = raw.get("confidence", 0.0)
if not isinstance(confidence, (int, float)):
confidence = 0.0
semantic_parameters.append(
{
"name": name,
"unit": token(raw.get("unit")),
"semantic_aliases": list_tokens(raw.get("semantic_aliases")),
"affects_feature_roles": list_tokens(
raw.get("affects_feature_roles")
),
"expected_change": token(edit_contract.get("expected_change")),
"confidence_class": (
"high"
if float(confidence) >= 0.8
else "medium"
if float(confidence) >= 0.6
else "low"
),
"epistemic_status": token(raw.get("epistemic_status")),
}
)
geometry = (
payload.get("geometry_evidence")
if isinstance(payload.get("geometry_evidence"), dict)
else {}
)
return {
"case_id": case_id,
"extractor_version": str(payload.get("extractor_version") or "0"),
"family": family,
"features": sorted(features),
"relations": sorted(relations),
"rules": rules,
"validations": validations,
"normalized_observations": observations,
"semantic_parameters": semantic_parameters,
"reconstruction_eligible": bool(
geometry.get(
"reconstruction_eligible",
geometry.get("valid", True)
and int(geometry.get("solid_count", 1)) == 1
and float(geometry.get("volume", 1.0)) > 0.0,
)
),
"rejection_reasons": list_tokens(geometry.get("rejection_reasons")),
"reconstruction": normalize_reconstruction(
design_ir.get("reconstruction_evidence")
),
@@ -311,11 +379,18 @@ def load_cases(root: Path) -> tuple[list[dict[str, Any]], dict[str, int]]:
if not isinstance(payload, dict):
raise ValueError("root must be an object")
case = normalize_case(payload)
if not case["reconstruction_eligible"]:
rejected += 1
continue
if not case["features"] and not case["rules"] and not case["relations"]:
rejected += 1
continue
if case["case_id"] in cases:
duplicates += 1
if case["extractor_version"] > cases[case["case_id"]][
"extractor_version"
]:
cases[case["case_id"]] = case
continue
cases[case["case_id"]] = case
except (OSError, json.JSONDecodeError, ValueError, TypeError):
@@ -555,7 +630,9 @@ def audit_value(value: Any, path: tuple[str, ...], errors: list[str]) -> None:
audit_value(child, path + (str(index),), errors)
elif isinstance(value, (int, float)) and not isinstance(value, bool):
leaf = path[-1] if path else ""
if leaf not in ALLOWED_NUMERIC_KEYS:
if leaf not in ALLOWED_NUMERIC_KEYS and not leaf.endswith(
ALLOWED_NUMERIC_SUFFIXES
):
errors.append(f"{'.'.join(path)}: numeric value is not generalized metadata")
@@ -646,7 +723,7 @@ def extract_folder(input_dir: Path, output_dir: Path) -> dict[str, Any]:
digest = payload.get("provenance", {}).get("source_sha256")
if (
isinstance(digest, str)
and payload.get("extractor_version") == "3.0"
and payload.get("extractor_version") == "4.0"
):
known_hashes[digest] = path
except (OSError, json.JSONDecodeError, AttributeError):
@@ -711,6 +788,18 @@ def prepare_semantic_batch(
"candidate_rules": case["rules"],
"validation_targets": case["validations"],
"reconstruction_evidence": case["reconstruction"],
"semantic_parameter_roles": [
{
"name": item["name"],
"unit": item["unit"],
"semantic_aliases": item["semantic_aliases"],
"affects_feature_roles": item["affects_feature_roles"],
"expected_change": item["expected_change"],
"confidence_class": item["confidence_class"],
"epistemic_status": item["epistemic_status"],
}
for item in case["semantic_parameters"]
],
"dimensionless_observation_roles": [
{
"name": item["name"],
@@ -753,6 +842,8 @@ def prepare_semantic_batch(
"validation_rule",
"failure_repair",
"dimensionless_distribution",
"parameter_naming",
"edit_strategy",
"reconstruction_grammar",
],
},
@@ -770,6 +861,8 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
"validation_rule",
"failure_repair",
"dimensionless_distribution",
"parameter_naming",
"edit_strategy",
"reconstruction_grammar",
}
for index, raw in enumerate(payload.get("proposals", [])):
@@ -785,6 +878,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
)
required_features = list_tokens(evidence.get("required_features"))
required_relations = list_tokens(evidence.get("required_relations"))
required_parameters = list_tokens(evidence.get("required_parameters"))
context_features = list_tokens(
evidence.get("context_features") or required_features
)
@@ -800,6 +894,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
if (
not required_features
and not required_relations
and not required_parameters
and observation_name == "unknown"
):
raise ValueError(f"proposal {proposal_id} has no evidence query")
@@ -814,6 +909,7 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
"evidence_query": {
"required_features": required_features,
"required_relations": required_relations,
"required_parameters": required_parameters,
"context_features": context_features,
},
"guidance": guidance,
@@ -833,6 +929,24 @@ def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
f"proposal {proposal_id} has an incomplete reconstruction grammar"
)
proposal["reconstruction_grammar"] = grammar
if kind in {"parameter_naming", "edit_strategy"}:
canonical_parameter = token(raw.get("canonical_parameter"))
if canonical_parameter == "unknown":
raise ValueError(
f"proposal {proposal_id} requires canonical_parameter"
)
proposal["canonical_parameter"] = canonical_parameter
proposal["semantic_aliases"] = list_tokens(
raw.get("semantic_aliases")
)
if kind == "edit_strategy":
expected_change = token(raw.get("expected_change"))
if expected_change == "unknown":
raise ValueError(
f"proposal {proposal_id} requires expected_change"
)
proposal["expected_change"] = expected_change
proposal["preserve_roles"] = list_tokens(raw.get("preserve_roles"))
for key in ("check", "repair", "failure_signature"):
value = string_without_instance_numbers(raw.get(key))
if value is not None:
@@ -849,10 +963,20 @@ def build_reviewed_library(
min_support: int,
min_confidence: float,
stats: dict[str, int],
validation_report: dict[str, Any] | None = None,
) -> dict[str, Any]:
promoted: list[dict[str, Any]] = []
candidates: list[dict[str, Any]] = []
family_counts = Counter(case["family"] for case in cases)
validated_methods = {
token(item.get("experience_id")): item
for item in (validation_report or {}).get("method_results", [])
if isinstance(item, dict)
and item.get("passed") is True
and int(item.get("independent_case_count", 0)) >= 3
and float(item.get("edit_contract_pass_rate", 0.0)) >= 1.0
and float(item.get("baseline_score_delta", 0.0)) > 0.0
}
def supports_reconstruction_grammar(
case: dict[str, Any], grammar: dict[str, Any]
@@ -901,6 +1025,9 @@ def build_reviewed_library(
required_relations = set(
proposal["evidence_query"]["required_relations"]
)
required_parameters = set(
proposal["evidence_query"].get("required_parameters", [])
)
context_features = set(
proposal["evidence_query"].get("context_features", required_features)
)
@@ -941,7 +1068,22 @@ def build_reviewed_library(
"p90": round(percentile(values, 0.90), 6),
"max": round(max(values), 6),
}
if eligible(support, item["confidence"], min_support, min_confidence):
evidence_eligible = eligible(
support, item["confidence"], min_support, min_confidence
)
validation = validated_methods.get(item["id"])
if evidence_eligible and validation is not None:
item["validation"] = {
"independent_case_count": int(
validation["independent_case_count"]
),
"edit_contract_pass_rate": float(
validation["edit_contract_pass_rate"]
),
"baseline_score_delta": float(
validation["baseline_score_delta"]
),
}
promoted.append(item)
else:
item.pop("distribution", None)
@@ -951,6 +1093,11 @@ def build_reviewed_library(
"required_support": min_support,
"remaining_support": max(0, min_support - support),
"required_confidence": min_confidence,
"validation_state": (
"validated_positive"
if validation is not None
else "awaiting_positive_heldout_ab"
),
"consumer_policy": "visible_for_review_but_not_available_to_cad_router",
}
)
@@ -966,6 +1113,9 @@ def build_reviewed_library(
for case in contextual
if required_features.issubset(case["features"])
and required_relations.issubset(case["relations"])
and required_parameters.issubset(
{item["name"] for item in case.get("semantic_parameters", [])}
)
and (
proposal["kind"] != "reconstruction_grammar"
or supports_reconstruction_grammar(
@@ -982,6 +1132,7 @@ def build_reviewed_library(
"when": {
"features": sorted(required_features),
"relations": sorted(required_relations),
"semantic_parameters": sorted(required_parameters),
},
"guidance": proposal["guidance"],
"semantic_rationale": proposal["semantic_rationale"],
@@ -993,7 +1144,24 @@ def build_reviewed_library(
item[key] = proposal[key]
if proposal["kind"] == "reconstruction_grammar":
item["reconstruction_grammar"] = proposal["reconstruction_grammar"]
if eligible(support, confidence, min_support, min_confidence):
if proposal["kind"] in {"parameter_naming", "edit_strategy"}:
item["canonical_parameter"] = proposal["canonical_parameter"]
item["semantic_aliases"] = proposal["semantic_aliases"]
if proposal["kind"] == "edit_strategy":
item["expected_change"] = proposal["expected_change"]
item["preserve_roles"] = proposal["preserve_roles"]
evidence_eligible = eligible(
support, confidence, min_support, min_confidence
)
validation = validated_methods.get(item["id"])
if evidence_eligible and validation is not None:
item["validation"] = {
"independent_case_count": int(validation["independent_case_count"]),
"edit_contract_pass_rate": float(
validation["edit_contract_pass_rate"]
),
"baseline_score_delta": float(validation["baseline_score_delta"]),
}
promoted.append(item)
else:
item.update(
@@ -1002,6 +1170,11 @@ def build_reviewed_library(
"required_support": min_support,
"remaining_support": max(0, min_support - support),
"required_confidence": min_confidence,
"validation_state": (
"validated_positive"
if validation is not None
else "awaiting_positive_heldout_ab"
),
"consumer_policy": "visible_for_review_but_not_available_to_cad_router",
}
)
@@ -1026,7 +1199,8 @@ def build_reviewed_library(
"single_case_promotion_allowed": False,
"llm_semantic_review_required": True,
"draft_evidence_verified": True,
"router_consumable": True,
"positive_heldout_ab_required": True,
"router_consumable": bool(promoted),
"minimum_support": min_support,
"minimum_confidence": min_confidence,
},
@@ -1115,6 +1289,11 @@ def build_parser() -> argparse.ArgumentParser:
)
publish_parser.add_argument("--min-support", type=int, default=20)
publish_parser.add_argument("--min-confidence", type=float, default=0.8)
publish_parser.add_argument(
"--validation-report",
type=Path,
help="Held-out per-method A/B and edit-contract validation report.",
)
distill_parser = subparsers.add_parser("distill")
distill_parser.add_argument(
@@ -1130,6 +1309,11 @@ def build_parser() -> argparse.ArgumentParser:
)
distill_parser.add_argument("--min-support", type=int, default=20)
distill_parser.add_argument("--min-confidence", type=float, default=0.8)
distill_parser.add_argument(
"--validation-report",
type=Path,
help="Held-out per-method A/B and edit-contract validation report.",
)
return parser
@@ -1205,12 +1389,18 @@ def main(argv: list[str] | None = None) -> int:
draft_path = args.draft
draft_payload = json.loads(draft_path.read_text(encoding="utf-8"))
proposals = normalize_proposals(draft_payload)
validation_report = None
if args.validation_report is not None:
validation_report = json.loads(
args.validation_report.read_text(encoding="utf-8")
)
library = build_reviewed_library(
cases,
proposals,
args.min_support,
args.min_confidence,
stats,
validation_report,
)
errors = audit_library(library)
if errors:
@@ -11,7 +11,7 @@ from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
EXTRACTOR_VERSION = "3.0"
EXTRACTOR_VERSION = "4.0"
def _rounded(value: float) -> float:
@@ -71,6 +71,184 @@ def _cardinality_class(count: int) -> str:
return "dense"
def _parameter_candidate(
name: str,
value: float | int,
unit: str,
confidence: float,
affects: list[str],
expected_change: str,
evidence_kind: str,
aliases: list[str] | None = None,
) -> dict[str, Any]:
"""Create an explicitly inferred, editable semantic parameter candidate."""
return {
"name": name,
"value": value if unit == "count" else _rounded(float(value)),
"unit": unit,
"editable_candidate": True,
"epistemic_status": "inferred_from_final_brep",
"confidence": _rounded(confidence),
"semantic_aliases": sorted(set(aliases or [])),
"affects_feature_roles": sorted(set(affects)),
"edit_contract": {
"expected_change": expected_change,
"preserve_roles": ["part_center", "primary_reference_frame"],
},
"source_evidence": {"kind": evidence_kind},
}
def _semantic_parameter_candidates(
surfaces: list[dict[str, Any]],
bbox_center: list[float],
bbox_size: list[float],
features: list[dict[str, Any]],
semantic_summary: dict[str, Any],
) -> list[dict[str, Any]]:
"""Name editable dimensions without pretending that STEP stored parameters."""
if not bbox_size or max(bbox_size) <= 1e-9:
return []
feature_roles = {
str(item.get("type"))
for item in features
if isinstance(item, dict) and item.get("type")
}
ordered_axes = sorted(
zip(("x", "y", "z"), bbox_size), key=lambda item: item[1], reverse=True
)
candidates = [
_parameter_candidate(
"overall_length",
ordered_axes[0][1],
"mm",
0.98,
["primary_envelope"],
"overall_length_changes",
f"bounding_box_{ordered_axes[0][0]}_span",
),
_parameter_candidate(
"overall_width",
ordered_axes[1][1],
"mm",
0.92,
["primary_envelope"],
"overall_width_changes",
f"bounding_box_{ordered_axes[1][0]}_span",
),
_parameter_candidate(
"overall_thickness",
ordered_axes[2][1],
"mm",
0.88,
["primary_envelope"],
"overall_thickness_changes",
f"bounding_box_{ordered_axes[2][0]}_span",
["plate_thickness"]
if "plate_like_body" in feature_roles
else ["minimum_envelope_span"],
),
]
dominant_axis = str(semantic_summary.get("dominant_axis", "z"))
transverse_axes = {"x": (1, 2), "y": (0, 2), "z": (0, 1)}[dominant_axis]
scale = max(bbox_size)
center_tolerance = max(scale * 0.005, 1e-4)
repeated_groups: dict[float, list[dict[str, Any]]] = defaultdict(list)
coaxial: list[dict[str, Any]] = []
for surface in surfaces:
if (
surface.get("type") != "cylinder"
or _canonical_axis(surface["axis"]) != dominant_axis
):
continue
radial_offset = _radial_offset(
surface["location"], bbox_center, dominant_axis
)
if radial_offset <= center_tolerance:
coaxial.append(surface)
else:
repeated_groups[round(float(surface["radius"]), 4)].append(surface)
repeated = max(repeated_groups.values(), key=len) if repeated_groups else []
unique_centers = {
(
round(float(item["location"][transverse_axes[0]]), 4),
round(float(item["location"][transverse_axes[1]]), 4),
)
for item in repeated
}
if len(unique_centers) >= 3:
member_radius = float(repeated[0]["radius"])
offsets = [
math.hypot(
first - bbox_center[transverse_axes[0]],
second - bbox_center[transverse_axes[1]],
)
for first, second in unique_centers
]
mean_offset = sum(offsets) / len(offsets)
circular = (
mean_offset > center_tolerance
and (max(offsets) - min(offsets)) / mean_offset <= 0.08
)
candidates.extend(
[
_parameter_candidate(
"repeated_feature_count",
len(unique_centers),
"count",
0.72,
["repeated_cylindrical_feature_pattern"],
"pattern_member_count_changes",
"unique_equal_radius_parallel_cylinder_axes",
["hole_count", "boss_count"],
),
_parameter_candidate(
"repeated_feature_diameter",
member_radius * 2.0,
"mm",
0.68,
["repeated_cylindrical_feature_pattern"],
"pattern_member_diameter_changes",
"equal_radius_cylindrical_surfaces",
["hole_diameter", "boss_diameter"],
),
]
)
if circular:
candidates.append(
_parameter_candidate(
"bolt_circle_diameter",
mean_offset * 2.0,
"mm",
0.82,
["circular_equal_radius_pattern"],
"pattern_radius_changes",
"equal_radial_offset_cylinder_axes",
["hole_spacing", "pattern_diameter"],
)
)
coaxial_radii = sorted(
{round(float(item["radius"]), 6) for item in coaxial}
)
if coaxial_radii:
candidates.append(
_parameter_candidate(
"coaxial_inner_diameter",
coaxial_radii[0] * 2.0,
"mm",
0.58,
["coaxial_cylindrical_stack"],
"coaxial_inner_diameter_changes",
"smallest_centered_cylindrical_surface",
["bore_diameter", "shaft_diameter"],
)
)
return candidates
def _surface_record(face: Any, index: int) -> dict[str, Any]:
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import (
@@ -702,8 +880,18 @@ def extract_step_case(source: Path) -> dict[str, Any]:
reconstruction_evidence = _reconstruction_evidence(
surfaces, features, constraints, semantic_summary
)
semantic_parameters = _semantic_parameter_candidates(
surfaces,
bbox_center,
bbox_size,
features,
semantic_summary,
)
digest = hashlib.sha256(source_bytes).hexdigest()
normalized_observations = semantic_summary.pop("normalized_observations", [])
solid_count = len(shape.solids())
volume = _rounded(shape.volume)
usable_solid = bool(shape.is_valid) and solid_count > 0 and volume > 0.0
return {
"schema_version": "2.0",
"extractor_version": EXTRACTOR_VERSION,
@@ -715,10 +903,22 @@ def extract_step_case(source: Path) -> dict[str, Any]:
"source_format": "step",
},
"geometry_evidence": {
"valid": bool(shape.is_valid),
"solid_count": len(shape.solids()),
"kernel_valid": bool(shape.is_valid),
"valid": usable_solid,
"reconstruction_eligible": usable_solid and solid_count == 1,
"rejection_reasons": [
reason
for condition, reason in (
(not bool(shape.is_valid), "kernel_invalid"),
(solid_count == 0, "no_closed_solid"),
(volume <= 0.0, "zero_volume"),
(solid_count > 1, "multi_solid_requires_segmentation"),
)
if condition
],
"solid_count": solid_count,
"face_count": len(shape.faces()),
"volume": _rounded(shape.volume),
"volume": volume,
"bounding_box": {
"min": bbox_min,
"max": bbox_max,
@@ -729,9 +929,11 @@ def extract_step_case(source: Path) -> dict[str, Any]:
"analytic_surfaces": surfaces,
},
"design_ir": {
"epistemic_status": "inferred_design_hypothesis_not_source_history",
"part_family": family,
"features": features,
"constraints": constraints,
"semantic_parameter_candidates": semantic_parameters,
"normalized_observations": normalized_observations,
"semantic_summary": semantic_summary,
"reconstruction_evidence": reconstruction_evidence,