#!/usr/bin/env python3 """Build editor projects from explicit plans using this checkout's real contracts. Natural-language interpretation belongs to the skill. This helper never infers components, connections or physical inputs. No running HTTP service is required. """ from __future__ import annotations import argparse from collections import Counter from copy import deepcopy import math from pathlib import Path import sys import xml.etree.ElementTree as ET REPOSITORY_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPOSITORY_ROOT)) sys.path.insert(0, str(Path(__file__).resolve().parent)) from simulation_skill import ( # noqa: E402 InputError, SkillCliError, _canonical_json, _ensure_distinct_new_output, _sha256, _write_new_file, configure_standard_streams, emit_json, load_source, nonnegative_integer, page_limit, stable_error_payload, ) def require(condition, message): if not condition: raise InputError("PROJECT_CONTRACT_INVALID", message) def record(value, allowed, required, path): require(isinstance(value, dict), f"{path} must be an object.") require(not set(value) - set(allowed), f"{path} has unknown fields: {sorted(set(value) - set(allowed))}.") require(set(required) <= set(value), f"{path} is missing fields: {sorted(set(required) - set(value))}.") return value def text_value(value): return isinstance(value, str) and bool(value.strip()) def finite_number(value): try: return type(value) in (int, float) and math.isfinite(value) except OverflowError: return False def parameter_value(value): return isinstance(value, str) or finite_number(value) def catalog_models(): from app.main import get_component_catalog catalog = get_component_catalog() require(catalog.get("schemaVersion") == 1, "Unsupported component catalog schema.") # The actual editor excludes the experimental test library. return [component for library in catalog["libraries"] if library["id"] != "experimental" for component in library["components"]] def build_project(plan): plan = deepcopy(plan) record(plan, {"name", "components", "connections", "simulation"}, {"name", "components", "connections", "simulation"}, "plan") require(text_value(plan["name"]), "plan.name must be nonempty.") require(isinstance(plan["components"], list) and plan["components"], "plan.components must be a nonempty array.") require(isinstance(plan["connections"], list), "plan.connections must be an array.") catalog = {model["modelType"]: model for model in catalog_models()} nodes, defaults = [], {} for index, item in enumerate(plan["components"]): location = f"components[{index}]" record(item, {"id", "modelType", "label", "parameters", "parameterUnits", "position", "rotation", "mirrored"}, {"id", "modelType"}, location) require(text_value(item["id"]), f"{location}.id must be nonempty.") require(isinstance(item["modelType"], str) and item["modelType"] in catalog, f"{location}.modelType is not in the editor catalog: {item['modelType']}.") model = catalog[item["modelType"]] definitions = {p["name"]: p for p in model["parameters"]} overrides = record(item.get("parameters", {}), definitions, (), f"{location}.parameters") units = record(item.get("parameterUnits", {}), definitions, (), f"{location}.parameterUnits") require(set(units) <= set(overrides), f"{location}: a selected unit requires an explicit parameter value.") require(all(parameter_value(v) for v in overrides.values()), f"{location}: parameters must be finite numbers or strings.") require(all(isinstance(v, str) for v in units.values()), f"{location}: units must be strings.") ports = [] for port in model["ports"]: snapshot = {key: port[key] for key in ("name", "kind", "domain", "nominalRole", "side")} if port.get("positiveFlowDirection") is not None: snapshot["positiveFlowDirection"] = port["positiveFlowDirection"] ports.append(snapshot) nodes.append({ "id": item["id"], "type": "simulationComponent", "position": item.get("position", {"x": (index % 4) * 300, "y": (index // 4) * 220}), "data": { "label": item.get("label", item["id"]), "componentType": model["type"], "modelType": model["modelType"], "modelVersion": model["modelVersion"], "ports": ports, "parameters": {**{key: p["default"] for key, p in definitions.items()}, **overrides}, "parameterUnits": {**{key: p["unit"] for key, p in definitions.items()}, **units}, "parameterScientificNotation": {}, "rotation": item.get("rotation", 0), "mirrored": item.get("mirrored", False), }, }) defaults[item["id"]] = sorted(set(definitions) - set(overrides)) edges = [] for index, item in enumerate(plan["connections"]): location = f"connections[{index}]" record(item, {"id", "source", "target"}, {"id", "source", "target"}, location) for side in ("source", "target"): record(item[side], {"component", "port"}, {"component", "port"}, f"{location}.{side}") edges.append({"id": item["id"], "source": item["source"]["component"], "sourceHandle": item["source"]["port"], "target": item["target"]["component"], "targetHandle": item["target"]["port"], "data": {"isContactEdge": False}}) project = {"projectSchemaVersion": 2, "name": plan["name"], "nodes": nodes, "edges": edges, "simulation": plan["simulation"]} validate_editor_shape(project) return project, defaults def validate_editor_shape(project): """Preflight the stricter editor shape; actual browser acceptance is separate.""" record(project, {"projectSchemaVersion", "name", "nodes", "edges", "simulation"}, {"projectSchemaVersion", "name", "nodes", "edges", "simulation"}, "project") require(type(project["projectSchemaVersion"]) is int and project["projectSchemaVersion"] in (1, 2), "Expected projectSchemaVersion 1 or 2.") require(isinstance(project["name"], str), "Project name must be a string.") require(isinstance(project["nodes"], list) and project["nodes"], "Project nodes must be nonempty.") require(isinstance(project["edges"], list), "Project edges must be an array.") simulation = record(project["simulation"], {"t_start", "t_stop", "step", "max_step", "method"}, {"t_start", "t_stop", "step", "max_step", "method"}, "simulation") require(isinstance(simulation["method"], str) and simulation["method"] in ("RK45", "BDF"), "Only RK45 and BDF are executable.") require(all(parameter_value(simulation[k]) for k in ("t_start", "t_stop", "step", "max_step")), "Simulation inputs must be finite numbers or expressions.") node_ids, edge_ids = set(), set() for node in project["nodes"]: require(isinstance(node, dict), "Each node must be an object.") ident = node.get("id") require(text_value(ident) and ident not in node_ids, "Node IDs must be nonempty and unique.") node_ids.add(ident) require(node.get("type") == "simulationComponent", f"{ident}: invalid editor node type.") position = node.get("position") require(isinstance(position, dict) and all(finite_number(position.get(k)) for k in ("x", "y")), f"{ident}: position requires finite x/y.") data = node.get("data") require(isinstance(data, dict), f"{ident}: data must be an object.") require(isinstance(data.get("label"), str) and all(text_value(data.get(k)) for k in ("componentType", "modelType")), f"{ident}: label and component/model type are required.") require("modelVersion" not in data or isinstance(data["modelVersion"], str), f"{ident}: invalid modelVersion.") require(type(data.get("rotation")) in (int, float) and data["rotation"] in (0, 90, 180, 270) and type(data.get("mirrored")) is bool, f"{ident}: invalid rotation/mirrored.") parameters = data.get("parameters") require(isinstance(parameters, dict) and all(parameter_value(v) for v in parameters.values()), f"{ident}: parameters must be finite numbers or strings.") units = data.get("parameterUnits", {}) require(isinstance(units, dict) and all(isinstance(v, str) for v in units.values()), f"{ident}: invalid parameterUnits.") require(isinstance(data.get("parameterScientificNotation", {}), dict), f"{ident}: invalid scientific notation metadata.") ports = data.get("ports") require(isinstance(ports, list), f"{ident}: ports must be objects, not strings.") names = set() for port in ports: require(isinstance(port, dict), f"{ident}: invalid port snapshot.") name = port.get("name") require(text_value(name) and name not in names, f"{ident}: port names must be nonempty and unique.") names.add(name) require(port.get("kind") in ("physical", "signal") and text_value(port.get("domain")) and port.get("nominalRole") in ("inlet", "outlet", "bidirectional", "input", "output") and port.get("side") in ("left", "right"), f"{ident}.{name}: invalid port snapshot.") require("positiveFlowDirection" not in port or port["positiveFlowDirection"] == "intoComponent", f"{ident}.{name}: omit null positiveFlowDirection for browser import.") for edge in project["edges"]: require(isinstance(edge, dict), "Each edge must be an object.") ident = edge.get("id") require(text_value(ident) and ident not in edge_ids, "Edge IDs must be nonempty and unique.") edge_ids.add(ident) require(all(text_value(edge.get(k)) for k in ("source", "target", "sourceHandle", "targetHandle")), f"{ident}: explicit endpoints and handles are required.") require(edge["source"] in node_ids and edge["target"] in node_ids, f"{ident}: missing endpoint component.") data = edge.get("data") require(isinstance(data, dict) and type(data.get("isContactEdge")) is bool, f"{ident}: data.isContactEdge must be boolean.") if "routePoints" in data: require(isinstance(data["routePoints"], list) and all( isinstance(p, dict) and all(finite_number(p.get(k)) for k in ("x", "y")) for p in data["routePoints"]), f"{ident}: invalid routePoints.") def validate_project(project): from app.main import (ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network, validate_reactflow_component_contract) from app.project_parameters import prepare_project, version_warning from app.simulation.registry import get_component_model_spec validate_editor_shape(project) available = {model["modelType"] for model in catalog_models()} require(all(node["data"]["modelType"] in available for node in project["nodes"]), "Project contains a model that is not available in the editor catalog.") try: normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(project)) xml = build_reactflow_system_xml(normalized) connected = Counter(endpoint for edge in normalized.edges for endpoint in ( (edge.source, edge.sourceHandle), (edge.target, edge.targetHandle))) # Store the full registered snapshot, but check only currently active ports. for node in normalized.nodes: spec = get_component_model_spec(node.data.modelType) parameters = validate_reactflow_component_contract(node, spec) for port in spec.active_ports(parameters): count = connected[(node.id, port.name)] require(count == 1, f"{node.id}.{port.name}: expected one connection, found {count}.") compiled = compile_reactflow_network(normalized).as_interface_dict() except (ValueError, KeyError) as exc: raise InputError("PROJECT_EXECUTION_INVALID", str(exc)) from exc warnings = [version_warning(notices)] if notices else [] report = { "ok": True, "projectSchemaVersion": project["projectSchemaVersion"], "componentCount": len(project["nodes"]), "connectionCount": len(project["edges"]), "warnings": warnings, "checks": {"editorShape": "passed", "activePorts": "passed", "xmlV3": "passed", "networkCompile": "passed", "browserImport": "notRun", "simulation": "notRun"}, "compiledComponentCount": len(compiled.get("components", [])), } return report, xml def semantic_signature(xml): """Compare SI execution inputs, ignoring layout and reversible edge direction.""" root = ET.fromstring(xml) simulation = root.find("Simulation") return { "simulation": {k: v if k == "method" else float(v) for k, v in simulation.attrib.items()}, "components": {c.attrib["id"]: { "type": c.attrib["type"], "modelVersion": c.attrib["modelVersion"], "parameters": {p.attrib["name"]: float(p.attrib["value"]) for p in c.findall("Parameter")}, } for c in root.findall("Components/Component")}, "connections": {c.attrib["id"]: sorted((e.attrib["component"], e.attrib["port"]) for e in c) for c in root.findall("Connections/Connection")}, } def main(argv=None): configure_standard_streams() parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) catalog = sub.add_parser("catalog", help="Read current local catalog; filter before requesting full model details.") catalog.add_argument("--query", default="") catalog.add_argument("--model", action="append", default=[]) catalog.add_argument("--offset", type=nonnegative_integer, default=0) catalog.add_argument("--limit", type=page_limit, default=20) build = sub.add_parser("build", help="Build and validate JSON v2 from an explicit plan.") build.add_argument("input") build.add_argument("--output", required=True) check = sub.add_parser("check", help="Check editor shape, active connections, XML and network compilation.") check.add_argument("input") check.add_argument("--xml-output") compare = sub.add_parser("compare", help="Check two projects and compare their normalized execution inputs.") compare.add_argument("input") compare.add_argument("roundtrip") args = parser.parse_args(argv) try: if args.command == "catalog": models = catalog_models() available = {m["modelType"] for m in models} require(set(args.model) <= available, f"Unknown models: {sorted(set(args.model) - available)}.") models = [m for m in models if (not args.model or m["modelType"] in args.model) and args.query.casefold() in (m["modelType"] + " " + m["label"]).casefold()] page = models[args.offset:args.offset + args.limit] if not args.model: page = [{k: m[k] for k in ("modelType", "label", "modelVersion")} for m in page] emit_json({"ok": True, "command": "catalog", "source": "localCheckout", "models": page, "total": len(models), "nextOffset": args.offset + len(page), "hasMore": args.offset + len(page) < len(models)}) return 0 source = load_source(args.input, "json") if args.command == "build": output = _ensure_distinct_new_output(source.path, args.output) project, defaults = build_project(source.parsed) report, _ = validate_project(project) data = _canonical_json(project) _write_new_file(output, data) report.update(output=str(output), sha256=_sha256(data), defaultsUsed=defaults) else: report, xml = validate_project(source.parsed) if args.command == "check" and args.xml_output: output = _ensure_distinct_new_output(source.path, args.xml_output) _write_new_file(output, xml) report["xmlOutput"] = str(output) if args.command == "compare": roundtrip = load_source(args.roundtrip, "json") other_report, other_xml = validate_project(roundtrip.parsed) before, after = semantic_signature(xml), semantic_signature(other_xml) changed = [key for key in before if before[key] != after[key]] require(not changed, f"Round-trip changed execution inputs: {', '.join(changed)}.") report.update(executionEquivalent=True, roundtripSha256=roundtrip.sha256, roundtripWarnings=other_report["warnings"]) report.update(command=args.command, sourceSha256=source.sha256) emit_json(report) return 0 except SkillCliError as exc: emit_json(stable_error_payload(exc), stream=sys.stderr) return exc.exit_code except (ValueError, KeyError) as exc: emit_json(stable_error_payload(InputError("PROJECT_CONTRACT_INVALID", str(exc))), stream=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())