Files
cadSet/cad-experience-plugin/skills/cad-experience-builder/scripts/cad_experience.py
T

1379 lines
51 KiB
Python
Executable File

#!/usr/bin/env python3
"""Induce and query generalized CAD experience without leaking case geometry."""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import itertools
import json
import math
import re
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from statistics import median
from typing import Any, Iterable
SCHEMA_VERSION = "2.0"
TOKEN_RE = re.compile(r"^[a-z][a-z0-9_.+-]*$")
NUMERIC_LITERAL_RE = re.compile(r"(?<![a-zA-Z])[-+]?\d+(?:\.\d+)?")
FORBIDDEN_KEYS = {
"parameters",
"parameter_examples",
"coordinates",
"coordinate",
"center",
"location",
"axis_origin",
"source_sha256",
"source_path",
"manifest_path",
"face_refs",
"surface_ids",
"evidence",
"diameter",
"radius",
"height",
"length",
"depth",
"spacing",
"pcd",
}
ALLOWED_NUMERIC_KEYS = {
"support",
"confidence",
"frequency",
"sample_count",
"accepted_case_count",
"duplicate_case_count",
"rejected_case_count",
"family_count",
"minimum_support",
"minimum_confidence",
"p10",
"median",
"p90",
"min",
"max",
"count",
"promoted_experience_count",
"candidate_experience_count",
"required_support",
"remaining_support",
"required_confidence",
"llm_proposal_count",
}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def content_hash(payload: Any) -> str:
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
def token(value: Any, default: str = "unknown") -> str:
candidate = str(value or "").strip().lower().replace(" ", "_")
return candidate if TOKEN_RE.fullmatch(candidate) else default
def string_without_instance_numbers(value: Any) -> str | None:
if not isinstance(value, str):
return None
rendered = " ".join(value.split())
if not rendered or NUMERIC_LITERAL_RE.search(rendered):
return None
return rendered
def list_tokens(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return sorted({item for raw in value if (item := token(raw)) != "unknown"})
def normalize_stage(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
stage_id = token(raw.get("id"))
operation = token(raw.get("operation"))
if stage_id == "unknown" or operation == "unknown":
return None
return {
"id": stage_id,
"operation": operation,
"feature_roles": list_tokens(raw.get("feature_roles")),
"reference_roles": list_tokens(raw.get("reference_roles")),
}
def normalize_reconstruction(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {
"parameter_roles": [],
"datum_roles": [],
"feature_roles": [],
"relation_roles": [],
"canonical_stages": [],
"cardinality_classes": [],
"validation_roles": [],
}
private_cardinality = (
raw.get("private_cardinality_evidence")
if isinstance(raw.get("private_cardinality_evidence"), dict)
else {}
)
class_values = private_cardinality.get("surface_type_classes", {})
cardinality_classes = list_tokens(raw.get("cardinality_classes"))
if isinstance(class_values, dict):
cardinality_classes.extend(
token(f"{kind}_{value}")
for kind, value in class_values.items()
)
for role in (
"feature_role_count_class",
"coaxial_radius_role_count_class",
"repeated_member_count_class",
):
value = private_cardinality.get(role)
if value:
cardinality_classes.append(token(f"{role}.{value}"))
return {
"parameter_roles": list_tokens(raw.get("parameter_roles")),
"datum_roles": list_tokens(raw.get("datum_roles")),
"feature_roles": list_tokens(raw.get("feature_roles")),
"relation_roles": list_tokens(raw.get("relation_roles")),
"canonical_stages": [
stage
for item in raw.get("canonical_stages", [])
if (stage := normalize_stage(item)) is not None
]
if isinstance(raw.get("canonical_stages"), list)
else [],
"cardinality_classes": sorted(
{item for item in cardinality_classes if item != "unknown"}
),
"validation_roles": list_tokens(raw.get("validation_roles")),
}
def percentile(values: list[float], fraction: float) -> float:
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
position = (len(ordered) - 1) * fraction
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return ordered[lower]
weight = position - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def iter_json_files(root: Path) -> Iterable[Path]:
if root.is_file():
yield root
return
yield from sorted(path for path in root.rglob("*.json") if path.is_file())
def normalize_rule(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
rule_id = token(raw.get("id"))
statement = string_without_instance_numbers(raw.get("statement") or raw.get("guidance"))
if rule_id == "unknown" or statement is None:
return None
result: dict[str, Any] = {
"id": rule_id,
"scope": list_tokens(raw.get("scope")),
"statement": statement,
}
for key in ("failure_signature", "repair", "check"):
clean = string_without_instance_numbers(raw.get(key))
if clean is not None:
result[key] = clean
return result
def normalize_case(payload: dict[str, Any]) -> dict[str, Any]:
design_ir = payload.get("design_ir") if isinstance(payload.get("design_ir"), dict) else {}
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))
family = token(design_ir.get("part_family") or payload.get("part_family"))
if family.endswith("_candidate"):
family = family.removesuffix("_candidate")
features: set[str] = set()
raw_features = design_ir.get("features", payload.get("features", []))
if isinstance(raw_features, list):
for feature in raw_features:
kind = feature.get("type") if isinstance(feature, dict) else feature
normalized = token(kind)
if normalized.endswith("_candidate"):
normalized = normalized.removesuffix("_candidate")
if normalized != "unknown":
features.add(normalized)
relations: set[str] = set()
raw_relations = design_ir.get("constraints", payload.get("semantic_relations", []))
if isinstance(raw_relations, list):
for relation in raw_relations:
relation_id = (
relation.get("id") or relation.get("type")
if isinstance(relation, dict)
else relation
)
normalized = token(relation_id)
if normalized != "unknown":
relations.add(normalized)
rules = [
normalized
for raw in experience.get("rules", payload.get("candidate_rules", []))
if (normalized := normalize_rule(raw)) is not None
]
validations = [
normalized
for raw in experience.get("validation_targets", payload.get("validation_targets", []))
if (
normalized := normalize_rule(
{
"id": raw.get("id") if isinstance(raw, dict) else None,
"scope": raw.get("scope", []) if isinstance(raw, dict) else [],
"statement": raw.get("check") if isinstance(raw, dict) else None,
}
)
)
is not None
]
observations: list[dict[str, Any]] = []
raw_observations = design_ir.get(
"normalized_observations", payload.get("normalized_observations", [])
)
if isinstance(raw_observations, list):
for observation in raw_observations:
if not isinstance(observation, dict):
continue
name = token(observation.get("name"))
numerator = token(observation.get("numerator_role"))
denominator = token(observation.get("denominator_role"))
value = observation.get("value")
if (
name != "unknown"
and numerator != "unknown"
and denominator != "unknown"
and isinstance(value, (int, float))
and math.isfinite(float(value))
and 0.0 <= float(value) <= 10.0
):
observations.append(
{
"name": name,
"numerator_role": numerator,
"denominator_role": denominator,
"value": float(value),
}
)
return {
"case_id": case_id,
"family": family,
"features": sorted(features),
"relations": sorted(relations),
"rules": rules,
"validations": validations,
"normalized_observations": observations,
"reconstruction": normalize_reconstruction(
design_ir.get("reconstruction_evidence")
),
}
def load_cases(root: Path) -> tuple[list[dict[str, Any]], dict[str, int]]:
cases: dict[str, dict[str, Any]] = {}
duplicates = 0
rejected = 0
for path in iter_json_files(root):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("root must be an object")
case = normalize_case(payload)
if not case["features"] and not case["rules"] and not case["relations"]:
rejected += 1
continue
if case["case_id"] in cases:
duplicates += 1
continue
cases[case["case_id"]] = case
except (OSError, json.JSONDecodeError, ValueError, TypeError):
rejected += 1
return list(cases.values()), {
"duplicate_case_count": duplicates,
"rejected_case_count": rejected,
}
def eligible(
support: int, confidence: float, min_support: int, min_confidence: float
) -> bool:
return support >= min_support and confidence >= min_confidence
def induce(
cases: list[dict[str, Any]],
min_support: int,
min_confidence: float,
stats: dict[str, int],
) -> dict[str, Any]:
total = len(cases)
experiences: list[dict[str, Any]] = []
candidates: list[dict[str, Any]] = []
family_counts = Counter(case["family"] for case in cases)
def record(item: dict[str, Any], support: int, confidence: float) -> None:
item["support"] = support
item["confidence"] = round(confidence, 6)
if eligible(support, confidence, min_support, min_confidence):
experiences.append(item)
return
candidate = {
key: value for key, value in item.items() if key != "distribution"
}
candidate.update(
{
"promotion_state": "candidate",
"required_support": min_support,
"remaining_support": max(0, min_support - support),
"required_confidence": min_confidence,
"consumer_policy": "visible_for_review_but_not_available_to_cad_router",
}
)
candidates.append(candidate)
family_cases: dict[str, list[dict[str, Any]]] = defaultdict(list)
for case in cases:
family_cases[case["family"]].append(case)
for family, rows in sorted(family_cases.items()):
pair_counts: Counter[tuple[str, str]] = Counter(
pair
for case in rows
for pair in itertools.combinations(case["features"], 2)
)
for pair, support in sorted(pair_counts.items()):
confidence = support / len(rows)
record(
{
"id": f"motif.{family}.{pair[0]}+{pair[1]}",
"kind": "feature_motif",
"scope": [family],
"when": {"features": list(pair)},
"guidance": "Treat these feature roles as a reusable composition.",
},
support,
confidence,
)
relation_counts = Counter(
relation for case in rows for relation in set(case["relations"])
)
for relation_id, support in sorted(relation_counts.items()):
confidence = support / len(rows)
record(
{
"id": f"constraint.{family}.{relation_id}",
"kind": "constraint",
"scope": [family],
"when": {"relation": relation_id},
"guidance": "Preserve this semantic relationship when its participating features are present.",
},
support,
confidence,
)
rule_groups: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
for case in cases:
for rule in case["rules"]:
rule_groups[rule["id"]].append((case, rule))
for rule_id, rows in sorted(rule_groups.items()):
variants = Counter(canonical_json(rule) for _, rule in rows)
rendered, support = variants.most_common(1)[0]
rule = json.loads(rendered)
relevant_total = sum(
1 for case in cases if not rule["scope"] or case["family"] in rule["scope"]
)
denominator = relevant_total or total
confidence = support / denominator if denominator else 0.0
item: dict[str, Any] = {
"id": f"rule.{rule_id}",
"kind": "failure_repair" if "repair" in rule else "design_rule",
"scope": rule["scope"] or ["global"],
"when": {"rule": rule_id},
"guidance": rule["statement"],
}
for key in ("failure_signature", "repair", "check"):
if key in rule:
item[key] = rule[key]
record(item, support, confidence)
validation_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for case in cases:
for validation in case["validations"]:
validation_groups[validation["id"]].append(validation)
for validation_id, rows in sorted(validation_groups.items()):
variants = Counter(canonical_json(row) for row in rows)
rendered, support = variants.most_common(1)[0]
validation = json.loads(rendered)
confidence = support / total if total else 0.0
record(
{
"id": f"validation.{validation_id}",
"kind": "validation_rule",
"scope": validation["scope"] or ["global"],
"when": {"validation": validation_id},
"check": validation["statement"],
},
support,
confidence,
)
observation_groups: dict[tuple[str, str, str], list[float]] = defaultdict(list)
for case in cases:
seen_names: set[str] = set()
for observation in case["normalized_observations"]:
if observation["name"] in seen_names:
continue
seen_names.add(observation["name"])
key = (
observation["name"],
observation["numerator_role"],
observation["denominator_role"],
)
observation_groups[key].append(observation["value"])
for (name, numerator, denominator), values in sorted(observation_groups.items()):
support = len(values)
confidence = support / total if total else 0.0
record(
{
"id": f"distribution.{name}",
"kind": "dimensionless_distribution",
"scope": ["global"],
"when": {
"ratio": {
"numerator_role": numerator,
"denominator_role": denominator,
}
},
"guidance": "Use this distribution only to fill an unspecified relationship; explicit user dimensions take priority.",
"distribution": {
"sample_count": support,
"min": round(min(values), 6),
"p10": round(percentile(values, 0.10), 6),
"median": round(median(values), 6),
"p90": round(percentile(values, 0.90), 6),
"max": round(max(values), 6),
},
},
support,
confidence,
)
sorted_experiences = sorted(experiences, key=lambda item: item["id"])
sorted_candidates = sorted(candidates, key=lambda item: item["id"])
kind_counts = Counter(item["kind"] for item in sorted_experiences)
candidate_kind_counts = Counter(item["kind"] for item in sorted_candidates)
scope_counts = Counter(
scope
for item in sorted_experiences
for scope in item.get("scope", ["global"])
)
return {
"schema_version": SCHEMA_VERSION,
"library_kind": "generalized_cad_experience",
"generated_at": utc_now(),
"status": (
"ready"
if experiences
else "collecting_evidence"
if candidates
else "empty"
),
"policy": {
"instance_parameters_allowed": False,
"absolute_coordinates_allowed": False,
"single_case_promotion_allowed": False,
"minimum_support": min_support,
"minimum_confidence": min_confidence,
},
"corpus_summary": {
"accepted_case_count": total,
"duplicate_case_count": stats["duplicate_case_count"],
"rejected_case_count": stats["rejected_case_count"],
"family_count": len(family_counts),
},
"experience_summary": {
"promoted_experience_count": len(sorted_experiences),
"candidate_experience_count": len(sorted_candidates),
"by_kind": [
{"kind": kind, "count": count}
for kind, count in sorted(kind_counts.items())
],
"by_scope": [
{"scope": scope, "count": count}
for scope, count in sorted(scope_counts.items())
],
"candidate_by_kind": [
{"kind": kind, "count": count}
for kind, count in sorted(candidate_kind_counts.items())
],
},
"experiences": sorted_experiences,
"candidate_experiences": sorted_candidates,
}
def audit_value(value: Any, path: tuple[str, ...], errors: list[str]) -> None:
if isinstance(value, dict):
for key, child in value.items():
if key in FORBIDDEN_KEYS:
errors.append(f"{'.'.join(path + (key,))}: forbidden instance key")
audit_value(child, path + (key,), errors)
elif isinstance(value, list):
for index, child in enumerate(value):
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:
errors.append(f"{'.'.join(path)}: numeric value is not generalized metadata")
def audit_library(payload: dict[str, Any]) -> list[str]:
errors: list[str] = []
if payload.get("schema_version") != SCHEMA_VERSION:
errors.append("schema_version: expected 2.0")
if payload.get("library_kind") != "generalized_cad_experience":
errors.append("library_kind: expected generalized_cad_experience")
audit_value(payload, (), errors)
return errors
def query_library(
payload: dict[str, Any], family: str | None, features: set[str]
) -> dict[str, Any]:
selected: list[dict[str, Any]] = []
normalized_family = token(family) if family else None
for item in payload.get("experiences", []):
scope = set(item.get("scope", []))
if "global" not in scope:
if not normalized_family or normalized_family not in scope:
continue
required = set(item.get("when", {}).get("features", []))
if required and features and not required.issubset(features):
continue
selected.append(item)
return {
"schema_version": SCHEMA_VERSION,
"context_kind": "generalized_cad_experience_query",
"family": normalized_family,
"requested_features": sorted(features),
"experiences": selected,
"policy": {
"contains_instance_parameters": False,
"contains_absolute_coordinates": False,
},
}
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 extract_step_case(source: Path) -> dict[str, Any]:
script = Path(__file__).with_name("step_to_case.py")
spec = importlib.util.spec_from_file_location("cad_experience_step_to_case", script)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load STEP extractor: {script}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.extract_step_case(source)
def plugin_root() -> Path:
return Path(__file__).resolve().parents[3]
def default_library_path() -> Path:
return plugin_root().parent / "cad-experience-library" / "library.json"
def parser_root() -> Path:
return plugin_root() / "parser"
def step_files(root: Path) -> list[Path]:
return sorted(
path
for path in root.rglob("*")
if path.is_file() and path.suffix.lower() in {".step", ".stp"}
)
def extract_folder(input_dir: Path, output_dir: Path) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
known_hashes: dict[str, Path] = {}
for path in iter_json_files(output_dir):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
digest = payload.get("provenance", {}).get("source_sha256")
if (
isinstance(digest, str)
and payload.get("extractor_version") == "3.0"
):
known_hashes[digest] = path
except (OSError, json.JSONDecodeError, AttributeError):
continue
created: list[str] = []
skipped: list[str] = []
failures: list[dict[str, str]] = []
for source in step_files(input_dir):
digest = hashlib.sha256(source.read_bytes()).hexdigest()
if digest in known_hashes:
skipped.append(str(source))
continue
try:
payload = extract_step_case(source)
output = output_dir / f"{source.stem}.{digest[:12]}.case.json"
write_json(output, payload)
known_hashes[digest] = output
created.append(str(output))
except Exception as exc:
failures.append({"source": str(source), "error": str(exc)})
return {
"schema_version": "1.0",
"input_dir": str(input_dir.resolve()),
"output_dir": str(output_dir.resolve()),
"discovered_step_count": len(step_files(input_dir)),
"created_case_count": len(created),
"skipped_duplicate_count": len(skipped),
"failed_count": len(failures),
"created_cases": created,
"failures": failures,
}
def default_review_dir() -> Path:
return plugin_root() / "work" / "review"
def prepare_semantic_batch(
cases: list[dict[str, Any]], existing_library: dict[str, Any] | None
) -> dict[str, Any]:
family_counts = Counter(case["family"] for case in cases)
return {
"schema_version": "1.0",
"batch_kind": "sanitized_cad_semantic_review",
"guardrails": {
"contains_instance_dimensions": False,
"contains_absolute_coordinates": False,
"llm_must_propose_methods_not_copy_answers": True,
"proposals_require_deterministic_evidence_verification": True,
},
"corpus_summary": {
"case_count": len(cases),
"family_count": len(family_counts),
},
"cases": [
{
"anonymous_case": f"case_{index:06d}",
"family": case["family"],
"features": case["features"],
"relations": case["relations"],
"candidate_rules": case["rules"],
"validation_targets": case["validations"],
"reconstruction_evidence": case["reconstruction"],
"dimensionless_observation_roles": [
{
"name": item["name"],
"numerator_role": item["numerator_role"],
"denominator_role": item["denominator_role"],
}
for item in case["normalized_observations"]
],
}
for index, case in enumerate(cases, start=1)
],
"existing_vocabulary": {
"promoted_ids": [
item.get("id")
for item in (existing_library or {}).get("experiences", [])
if isinstance(item, dict)
],
"candidate_ids": [
item.get("id")
for item in (existing_library or {}).get(
"candidate_experiences", []
)
if isinstance(item, dict)
],
},
"draft_contract": {
"draft_kind": "llm_generalized_experience_proposals",
"proposal_required_fields": [
"id",
"kind",
"scope",
"evidence_query",
"guidance",
"semantic_rationale",
],
"allowed_kinds": [
"feature_motif",
"constraint",
"design_rule",
"validation_rule",
"failure_repair",
"dimensionless_distribution",
"reconstruction_grammar",
],
},
}
def normalize_proposals(payload: dict[str, Any]) -> list[dict[str, Any]]:
if payload.get("draft_kind") != "llm_generalized_experience_proposals":
raise ValueError("draft_kind must be llm_generalized_experience_proposals")
proposals: list[dict[str, Any]] = []
allowed_kinds = {
"feature_motif",
"constraint",
"design_rule",
"validation_rule",
"failure_repair",
"dimensionless_distribution",
"reconstruction_grammar",
}
for index, raw in enumerate(payload.get("proposals", [])):
if not isinstance(raw, dict):
raise ValueError(f"proposal {index} must be an object")
proposal_id = token(raw.get("id"))
kind = token(raw.get("kind"))
scopes = list_tokens(raw.get("scope"))
evidence = (
raw.get("evidence_query")
if isinstance(raw.get("evidence_query"), dict)
else {}
)
required_features = list_tokens(evidence.get("required_features"))
required_relations = list_tokens(evidence.get("required_relations"))
context_features = list_tokens(
evidence.get("context_features") or required_features
)
observation_name = token(raw.get("observation_name"))
guidance = string_without_instance_numbers(raw.get("guidance"))
rationale = string_without_instance_numbers(raw.get("semantic_rationale"))
if proposal_id == "unknown":
raise ValueError(f"proposal {index} has an invalid id")
if kind not in allowed_kinds:
raise ValueError(f"proposal {proposal_id} has an unsupported kind")
if not scopes:
raise ValueError(f"proposal {proposal_id} has no scope")
if (
not required_features
and not required_relations
and observation_name == "unknown"
):
raise ValueError(f"proposal {proposal_id} has no evidence query")
if guidance is None or rationale is None:
raise ValueError(
f"proposal {proposal_id} guidance/rationale contains instance numbers"
)
proposal: dict[str, Any] = {
"id": proposal_id,
"kind": kind,
"scope": scopes,
"evidence_query": {
"required_features": required_features,
"required_relations": required_relations,
"context_features": context_features,
},
"guidance": guidance,
"semantic_rationale": rationale,
}
if observation_name != "unknown":
proposal["observation_name"] = observation_name
if kind == "reconstruction_grammar":
grammar = normalize_reconstruction(raw.get("reconstruction_grammar"))
if (
not grammar["parameter_roles"]
or not grammar["datum_roles"]
or not grammar["canonical_stages"]
or not grammar["validation_roles"]
):
raise ValueError(
f"proposal {proposal_id} has an incomplete reconstruction grammar"
)
proposal["reconstruction_grammar"] = grammar
for key in ("check", "repair", "failure_signature"):
value = string_without_instance_numbers(raw.get(key))
if value is not None:
proposal[key] = value
proposals.append(proposal)
if not proposals:
raise ValueError("draft contains no proposals")
return proposals
def build_reviewed_library(
cases: list[dict[str, Any]],
proposals: list[dict[str, Any]],
min_support: int,
min_confidence: float,
stats: dict[str, int],
) -> dict[str, Any]:
promoted: list[dict[str, Any]] = []
candidates: list[dict[str, Any]] = []
family_counts = Counter(case["family"] for case in cases)
def supports_reconstruction_grammar(
case: dict[str, Any], grammar: dict[str, Any]
) -> bool:
evidence = case.get("reconstruction", {})
for key in (
"parameter_roles",
"datum_roles",
"feature_roles",
"relation_roles",
"validation_roles",
):
required = set(grammar.get(key, []))
available = set(evidence.get(key, []))
if required and not required.issubset(available):
return False
available_stages = {
(item["id"], item["operation"]): item
for item in evidence.get("canonical_stages", [])
}
for required_stage in grammar.get("canonical_stages", []):
key = (required_stage["id"], required_stage["operation"])
available = available_stages.get(key)
if available is None:
return False
if not set(required_stage.get("feature_roles", [])).issubset(
available.get("feature_roles", [])
):
return False
if not set(required_stage.get("reference_roles", [])).issubset(
available.get("reference_roles", [])
):
return False
return True
for proposal in proposals:
scope = set(proposal["scope"])
relevant = [
case
for case in cases
if "global" in scope or case["family"] in scope
]
required_features = set(
proposal["evidence_query"]["required_features"]
)
required_relations = set(
proposal["evidence_query"]["required_relations"]
)
context_features = set(
proposal["evidence_query"].get("context_features", required_features)
)
if proposal["kind"] == "dimensionless_distribution":
matched = [
observation
for case in relevant
for observation in case["normalized_observations"]
if observation["name"] == proposal.get("observation_name")
]
support = len(matched)
item: dict[str, Any] = {
"id": proposal["id"],
"kind": proposal["kind"],
"scope": proposal["scope"],
"when": {
"ratio": {
"numerator_role": (
matched[0]["numerator_role"] if matched else "unknown"
),
"denominator_role": (
matched[0]["denominator_role"] if matched else "unknown"
),
}
},
"guidance": proposal["guidance"],
"semantic_rationale": proposal["semantic_rationale"],
"support": support,
"confidence": 1.0 if support else 0.0,
}
if matched:
values = [observation["value"] for observation in matched]
item["distribution"] = {
"sample_count": support,
"min": round(min(values), 6),
"p10": round(percentile(values, 0.10), 6),
"median": round(median(values), 6),
"p90": round(percentile(values, 0.90), 6),
"max": round(max(values), 6),
}
if eligible(support, item["confidence"], min_support, min_confidence):
promoted.append(item)
else:
item.pop("distribution", None)
item.update(
{
"promotion_state": "candidate",
"required_support": min_support,
"remaining_support": max(0, min_support - support),
"required_confidence": min_confidence,
"consumer_policy": "visible_for_review_but_not_available_to_cad_router",
}
)
candidates.append(item)
continue
contextual = [
case
for case in relevant
if context_features.issubset(case["features"])
]
supporting = [
case
for case in contextual
if required_features.issubset(case["features"])
and required_relations.issubset(case["relations"])
and (
proposal["kind"] != "reconstruction_grammar"
or supports_reconstruction_grammar(
case, proposal["reconstruction_grammar"]
)
)
]
support = len(supporting)
confidence = support / len(contextual) if contextual else 0.0
item: dict[str, Any] = {
"id": proposal["id"],
"kind": proposal["kind"],
"scope": proposal["scope"],
"when": {
"features": sorted(required_features),
"relations": sorted(required_relations),
},
"guidance": proposal["guidance"],
"semantic_rationale": proposal["semantic_rationale"],
"support": support,
"confidence": round(confidence, 6),
}
for key in ("check", "repair", "failure_signature"):
if key in proposal:
item[key] = proposal[key]
if proposal["kind"] == "reconstruction_grammar":
item["reconstruction_grammar"] = proposal["reconstruction_grammar"]
if eligible(support, confidence, min_support, min_confidence):
promoted.append(item)
else:
item.update(
{
"promotion_state": "candidate",
"required_support": min_support,
"remaining_support": max(0, min_support - support),
"required_confidence": min_confidence,
"consumer_policy": "visible_for_review_but_not_available_to_cad_router",
}
)
candidates.append(item)
promoted.sort(key=lambda item: item["id"])
candidates.sort(key=lambda item: item["id"])
kind_counts = Counter(item["kind"] for item in promoted)
candidate_kind_counts = Counter(item["kind"] for item in candidates)
scope_counts = Counter(
scope for item in promoted for scope in item.get("scope", ["global"])
)
return {
"schema_version": SCHEMA_VERSION,
"library_kind": "generalized_cad_experience",
"generated_at": utc_now(),
"induction_mode": "llm_proposals_with_deterministic_evidence_verification",
"status": "ready" if promoted else "collecting_evidence",
"policy": {
"instance_parameters_allowed": False,
"absolute_coordinates_allowed": False,
"single_case_promotion_allowed": False,
"llm_semantic_review_required": True,
"draft_evidence_verified": True,
"router_consumable": True,
"minimum_support": min_support,
"minimum_confidence": min_confidence,
},
"corpus_summary": {
"accepted_case_count": len(cases),
"duplicate_case_count": stats["duplicate_case_count"],
"rejected_case_count": stats["rejected_case_count"],
"family_count": len(family_counts),
},
"experience_summary": {
"llm_proposal_count": len(proposals),
"promoted_experience_count": len(promoted),
"candidate_experience_count": len(candidates),
"by_kind": [
{"kind": kind, "count": count}
for kind, count in sorted(kind_counts.items())
],
"by_scope": [
{"scope": scope, "count": count}
for scope, count in sorted(scope_counts.items())
],
"candidate_by_kind": [
{"kind": kind, "count": count}
for kind, count in sorted(candidate_kind_counts.items())
],
},
"experiences": promoted,
"candidate_experiences": candidates,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init")
init_parser.add_argument("output", type=Path)
induce_parser = subparsers.add_parser("induce")
induce_parser.add_argument("input", type=Path)
induce_parser.add_argument("--output", type=Path, required=True)
induce_parser.add_argument("--min-support", type=int, default=20)
induce_parser.add_argument("--min-confidence", type=float, default=0.8)
query_parser = subparsers.add_parser("query")
query_parser.add_argument("library", type=Path)
query_parser.add_argument("--family")
query_parser.add_argument("--features", default="")
query_parser.add_argument("--output", type=Path)
audit_parser = subparsers.add_parser("audit")
audit_parser.add_argument("library", type=Path)
extract_parser = subparsers.add_parser("extract")
extract_parser.add_argument("source", type=Path)
extract_parser.add_argument("--output", type=Path, required=True)
extract_folder_parser = subparsers.add_parser("extract-folder")
extract_folder_parser.add_argument(
"--input", type=Path, default=parser_root() / "input"
)
extract_folder_parser.add_argument(
"--output", type=Path, default=parser_root() / "output"
)
prepare_parser = subparsers.add_parser("prepare")
prepare_parser.add_argument(
"--input", type=Path, default=parser_root() / "output"
)
prepare_parser.add_argument(
"--library", type=Path, default=default_library_path()
)
prepare_parser.add_argument(
"--output",
type=Path,
default=default_review_dir() / "semantic-batch.json",
)
publish_parser = subparsers.add_parser("publish")
publish_parser.add_argument("--draft", type=Path, required=True)
publish_parser.add_argument(
"--input", type=Path, default=parser_root() / "output"
)
publish_parser.add_argument(
"--library", type=Path, default=default_library_path()
)
publish_parser.add_argument("--min-support", type=int, default=20)
publish_parser.add_argument("--min-confidence", type=float, default=0.8)
distill_parser = subparsers.add_parser("distill")
distill_parser.add_argument(
"--draft",
type=Path,
help="LLM-authored proposal draft. Without this, publishing is blocked.",
)
distill_parser.add_argument(
"--input", type=Path, default=parser_root() / "output"
)
distill_parser.add_argument(
"--library", type=Path, default=default_library_path()
)
distill_parser.add_argument("--min-support", type=int, default=20)
distill_parser.add_argument("--min-confidence", type=float, default=0.8)
pipeline_parser = subparsers.add_parser("pipeline")
pipeline_parser.add_argument("source", type=Path)
pipeline_parser.add_argument(
"--case-dir", type=Path, default=parser_root() / "output"
)
pipeline_parser.add_argument(
"--library", type=Path, default=default_library_path()
)
pipeline_parser.add_argument(
"--report-dir", type=Path, default=plugin_root() / "work" / "runs"
)
pipeline_parser.add_argument("--min-support", type=int, default=20)
pipeline_parser.add_argument("--min-confidence", type=float, default=0.8)
pipeline_parser.add_argument(
"--request",
help="Optionally run cad-router on a new request after publishing the library.",
)
pipeline_parser.add_argument(
"--output-format", action="append", default=["step"]
)
pipeline_parser.add_argument(
"--manufacturing",
choices=("unspecified", "machining", "printing", "laser-cutting", "concept"),
default="unspecified",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "init":
payload = induce(
[],
20,
0.8,
{"duplicate_case_count": 0, "rejected_case_count": 0},
)
write_json(args.output, payload)
print(args.output)
return 0
if args.command == "induce":
if args.min_support < 2:
raise SystemExit("--min-support must be at least 2")
if not 0.0 < args.min_confidence <= 1.0:
raise SystemExit("--min-confidence must be in (0, 1]")
cases, stats = load_cases(args.input)
payload = induce(cases, args.min_support, args.min_confidence, stats)
errors = audit_library(payload)
if errors:
raise SystemExit("experience audit failed:\n- " + "\n- ".join(errors))
write_json(args.output, payload)
print(args.output)
return 0
if args.command == "extract":
payload = extract_step_case(args.source)
write_json(args.output, payload)
print(args.output)
return 0
if args.command == "extract-folder":
result = extract_folder(args.input, args.output)
print(json.dumps(result, indent=2, ensure_ascii=False))
return 1 if result["failed_count"] else 0
if args.command == "prepare":
cases, _ = load_cases(args.input)
existing_library: dict[str, Any] | None = None
if args.library.is_file():
existing_library = json.loads(args.library.read_text(encoding="utf-8"))
packet = prepare_semantic_batch(cases, existing_library)
write_json(args.output, packet)
print(
json.dumps(
{
"semantic_batch": str(args.output.resolve()),
"case_count": len(cases),
"next_step": (
"Invoke $cad-experience-builder to author an LLM semantic "
"proposal draft, then run publish."
),
},
indent=2,
ensure_ascii=False,
)
)
return 0
if args.command in {"publish", "distill"}:
if args.command == "distill" and args.draft is None:
raise SystemExit(
"direct statistical distillation is disabled: invoke "
"$cad-experience-builder so the active model reviews the sanitized "
"semantic batch, then pass its draft with --draft"
)
if args.min_support < 2:
raise SystemExit("--min-support must be at least 2")
if not 0.0 < args.min_confidence <= 1.0:
raise SystemExit("--min-confidence must be in (0, 1]")
cases, stats = load_cases(args.input)
draft_path = args.draft
draft_payload = json.loads(draft_path.read_text(encoding="utf-8"))
proposals = normalize_proposals(draft_payload)
library = build_reviewed_library(
cases,
proposals,
args.min_support,
args.min_confidence,
stats,
)
errors = audit_library(library)
if errors:
raise SystemExit("experience audit failed:\n- " + "\n- ".join(errors))
write_json(args.library, library)
print(
json.dumps(
{
"input": str(args.input.resolve()),
"library": str(args.library.resolve()),
"llm_proposal_count": len(proposals),
"accepted_case_count": library["corpus_summary"][
"accepted_case_count"
],
"promoted_experience_count": len(library["experiences"]),
"status": library["status"],
},
indent=2,
ensure_ascii=False,
)
)
return 0
if args.command == "pipeline":
raise SystemExit(
"pipeline publishing is disabled because it bypasses LLM semantic "
"review; use extract-folder, then invoke $cad-experience-builder"
)
if args.min_support < 2:
raise SystemExit("--min-support must be at least 2")
if not 0.0 < args.min_confidence <= 1.0:
raise SystemExit("--min-confidence must be in (0, 1]")
case_payload = extract_step_case(args.source)
case_output = args.case_dir / f"{args.source.stem}.case.json"
write_json(case_output, case_payload)
cases, stats = load_cases(args.case_dir)
library = induce(cases, args.min_support, args.min_confidence, stats)
errors = audit_library(library)
if errors:
raise SystemExit("experience audit failed:\n- " + "\n- ".join(errors))
write_json(args.library, library)
route_output: Path | None = None
route_summary: dict[str, Any] | None = None
if args.request:
route_script = (
plugin_root().parent
/ "text-to-cad"
/ "skills"
/ "cad-router"
/ "scripts"
/ "route.py"
)
route_output = args.report_dir / f"{args.source.stem}.route.json"
route_command = [
sys.executable,
str(route_script),
args.request,
"--experience-library",
str(args.library),
"--manufacturing",
args.manufacturing,
"--manifest",
str(route_output),
]
for output_format in args.output_format:
route_command.extend(["--output", output_format])
completed = subprocess.run(
route_command,
check=True,
capture_output=True,
text=True,
)
route_summary = json.loads(completed.stdout)
report = {
"schema_version": "1.0",
"pipeline": "step_to_private_case_to_generalized_library",
"source": str(args.source.expanduser().resolve()),
"private_case": str(case_output.resolve()),
"published_library": str(args.library.resolve()),
"accepted_case_count": library["corpus_summary"]["accepted_case_count"],
"promoted_experience_count": len(library["experiences"]),
"promotion_status": library["status"],
"minimum_support": args.min_support,
"case_geometry_published": False,
"route_manifest": str(route_output.resolve()) if route_output else None,
"route": (
{
"selected_backend": route_summary["selected_backend"],
"part_family": route_summary["design_plan"]["part_family"],
"requested_feature_roles": route_summary["design_plan"][
"requested_feature_roles"
],
"matched_generalized_method_count": len(
route_summary["design_plan"]["generalized_methods"]
),
"experience_status": route_summary["design_plan"][
"experience_status"
],
}
if route_summary
else None
),
"message": (
"The STEP was distilled into private evidence. No generalized "
"experience is published until independent support reaches the threshold."
),
}
report_output = args.report_dir / f"{args.source.stem}.pipeline.json"
write_json(report_output, report)
print(
json.dumps(
{
"private_case": str(case_output),
"library": str(args.library),
"report": str(report_output),
"route": str(route_output) if route_output else None,
"promoted_experience_count": len(library["experiences"]),
},
ensure_ascii=False,
)
)
return 0
payload = json.loads(args.library.read_text(encoding="utf-8"))
errors = audit_library(payload)
if errors:
print("experience audit failed:\n- " + "\n- ".join(errors))
return 1
if args.command == "audit":
print("experience audit passed")
return 0
features = {token(item) for item in args.features.split(",") if item.strip()}
result = query_library(payload, args.family, features)
rendered = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
if args.output:
write_json(args.output, result)
print(args.output)
else:
print(rendered, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())