From 6d7a85cfac758839e1e6aa504af7cc46da8b63e2 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 14 Feb 2026 13:24:36 -0600 Subject: [PATCH] docs: add DAG client integration contract for silo-mod and runners --- docs/DAG_CLIENT_INTEGRATION.md | 395 +++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 docs/DAG_CLIENT_INTEGRATION.md diff --git a/docs/DAG_CLIENT_INTEGRATION.md b/docs/DAG_CLIENT_INTEGRATION.md new file mode 100644 index 0000000..00ac882 --- /dev/null +++ b/docs/DAG_CLIENT_INTEGRATION.md @@ -0,0 +1,395 @@ +# DAG Client Integration Contract + +**Status:** Draft +**Last Updated:** 2026-02-13 + +This document describes what silo-mod and Headless Create runners need to implement to integrate with the Silo dependency DAG and worker system. + +--- + +## 1. Overview + +The DAG system has two client-side integration points: + +1. **silo-mod workbench** (desktop) -- pushes DAG data to Silo on save or revision create. +2. **silorunner + silo-mod** (headless) -- extracts DAGs, validates features, and exports geometry as compute jobs. + +Both share the same Python codebase in the silo-mod repository. Desktop users call the code interactively; runners call it headlessly via `create --console`. + +--- + +## 2. DAG Sync Payload + +Clients push feature trees to Silo via: + +``` +PUT /api/items/{partNumber}/dag +Authorization: Bearer +Content-Type: application/json +``` + +### 2.1 Request Body + +```json +{ + "revision_number": 3, + "nodes": [ + { + "node_key": "Sketch001", + "node_type": "sketch", + "properties_hash": "a1b2c3d4e5f6...", + "metadata": { + "label": "Base Profile", + "constraint_count": 12 + } + }, + { + "node_key": "Pad001", + "node_type": "pad", + "properties_hash": "f6e5d4c3b2a1...", + "metadata": { + "label": "Main Extrusion", + "length": 25.0 + } + } + ], + "edges": [ + { + "source_key": "Sketch001", + "target_key": "Pad001", + "edge_type": "depends_on" + } + ] +} +``` + +### 2.2 Field Reference + +**Nodes:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `node_key` | string | yes | Unique within item+revision. Use Create's internal object name (e.g. `Sketch001`, `Pad003`). | +| `node_type` | string | yes | One of: `sketch`, `pad`, `pocket`, `fillet`, `chamfer`, `constraint`, `body`, `part`, `datum`. | +| `properties_hash` | string | no | SHA-256 hex digest of the node's parametric inputs. Used for memoization. | +| `validation_state` | string | no | One of: `clean`, `dirty`, `validating`, `failed`. Defaults to `clean`. | +| `metadata` | object | no | Arbitrary key-value pairs for display or debugging. | + +**Edges:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `source_key` | string | yes | The node that is depended upon. | +| `target_key` | string | yes | The node that depends on the source. | +| `edge_type` | string | no | One of: `depends_on` (default), `references`, `constrains`. | + +**Direction convention:** Edges point from dependency to dependent. If Pad001 depends on Sketch001, the edge is `source_key: "Sketch001"`, `target_key: "Pad001"`. + +### 2.3 Response + +```json +{ + "synced": true, + "node_count": 15, + "edge_count": 14 +} +``` + +--- + +## 3. Computing properties_hash + +The `properties_hash` enables memoization -- if a node's inputs haven't changed since the last validation, it can be skipped. Computing it: + +```python +import hashlib +import json + +def compute_properties_hash(feature_obj): + """Hash the parametric inputs of a Create feature.""" + inputs = {} + + if feature_obj.TypeId == "Sketcher::SketchObject": + # Hash geometry + constraints + inputs["geometry_count"] = feature_obj.GeometryCount + inputs["constraint_count"] = feature_obj.ConstraintCount + inputs["geometry"] = str(feature_obj.Shape.exportBrep()) + elif feature_obj.TypeId == "PartDesign::Pad": + inputs["length"] = feature_obj.Length.Value + inputs["type"] = str(feature_obj.Type) + inputs["reversed"] = feature_obj.Reversed + inputs["sketch"] = feature_obj.Profile[0].Name + # ... other feature types + + canonical = json.dumps(inputs, sort_keys=True) + return hashlib.sha256(canonical.encode()).hexdigest() +``` + +The exact inputs per feature type are determined by what parametric values affect the feature's geometry. Include anything that, if changed, would require recomputation. + +--- + +## 4. Feature Tree Walking + +To extract the DAG from a Create document: + +```python +import FreeCAD + +def extract_dag(doc): + """Walk a Create document and return nodes + edges.""" + nodes = [] + edges = [] + + for obj in doc.Objects: + # Skip non-feature objects + if not hasattr(obj, "TypeId"): + continue + + node_type = classify_type(obj.TypeId) + if node_type is None: + continue + + nodes.append({ + "node_key": obj.Name, + "node_type": node_type, + "properties_hash": compute_properties_hash(obj), + "metadata": { + "label": obj.Label, + "type_id": obj.TypeId, + } + }) + + # Walk dependencies via InList (objects this one depends on) + for dep in obj.InList: + if hasattr(dep, "TypeId") and classify_type(dep.TypeId): + edges.append({ + "source_key": dep.Name, + "target_key": obj.Name, + "edge_type": "depends_on", + }) + + return nodes, edges + + +def classify_type(type_id): + """Map Create TypeIds to DAG node types.""" + mapping = { + "Sketcher::SketchObject": "sketch", + "PartDesign::Pad": "pad", + "PartDesign::Pocket": "pocket", + "PartDesign::Fillet": "fillet", + "PartDesign::Chamfer": "chamfer", + "PartDesign::Body": "body", + "Part::Feature": "part", + "Sketcher::SketchConstraint": "constraint", + } + return mapping.get(type_id) +``` + +--- + +## 5. When to Push DAG Data + +Push the DAG to Silo in these scenarios: + +| Event | Trigger | Who | +|-------|---------|-----| +| User saves in silo-mod | On save callback | Desktop silo-mod workbench | +| User creates a revision | After `POST /api/items/{pn}/revisions` succeeds | Desktop silo-mod workbench | +| Runner extracts DAG | After `create-dag-extract` job completes | silorunner via `PUT /api/runner/jobs/{id}/dag` | +| Runner validates | After `create-validate` job, push updated validation states | silorunner via `PUT /api/runner/jobs/{id}/dag` | + +--- + +## 6. Runner Entry Points + +silo-mod must provide these Python entry points for headless invocation: + +### 6.1 silo.runner.dag_extract + +Extracts the feature DAG from a Create file and writes it as JSON. + +```python +# silo/runner.py + +def dag_extract(input_path, output_path): + """ + Extract feature DAG from a Create file. + + Args: + input_path: Path to the .kc (Kindred Create) file. + output_path: Path to write the JSON output. + + Output JSON format: + { + "nodes": [...], // Same format as DAG sync payload + "edges": [...] + } + """ + doc = FreeCAD.openDocument(input_path) + nodes, edges = extract_dag(doc) + + with open(output_path, 'w') as f: + json.dump({"nodes": nodes, "edges": edges}, f) + + FreeCAD.closeDocument(doc.Name) +``` + +### 6.2 silo.runner.validate + +Rebuilds all features and reports pass/fail per node. + +```python +def validate(input_path, output_path): + """ + Validate a Create file by rebuilding all features. + + Output JSON format: + { + "valid": true/false, + "nodes": [ + { + "node_key": "Pad001", + "state": "clean", // or "failed" + "message": null, // error message if failed + "properties_hash": "..." + } + ] + } + """ + doc = FreeCAD.openDocument(input_path) + doc.recompute() + + results = [] + all_valid = True + for obj in doc.Objects: + if not hasattr(obj, "TypeId"): + continue + node_type = classify_type(obj.TypeId) + if node_type is None: + continue + + state = "clean" + message = None + if hasattr(obj, "isValid") and not obj.isValid(): + state = "failed" + message = f"Feature {obj.Label} failed to recompute" + all_valid = False + + results.append({ + "node_key": obj.Name, + "state": state, + "message": message, + "properties_hash": compute_properties_hash(obj), + }) + + with open(output_path, 'w') as f: + json.dump({"valid": all_valid, "nodes": results}, f) + + FreeCAD.closeDocument(doc.Name) +``` + +### 6.3 silo.runner.export + +Exports geometry to STEP, IGES, or other formats. + +```python +def export(input_path, output_path, format="step"): + """ + Export a Create file to an external format. + + Args: + input_path: Path to the .kc file. + output_path: Path to write the exported file. + format: Export format ("step", "iges", "stl", "obj"). + """ + doc = FreeCAD.openDocument(input_path) + + import Part + shapes = [obj.Shape for obj in doc.Objects if hasattr(obj, "Shape")] + compound = Part.makeCompound(shapes) + + format_map = { + "step": "STEP", + "iges": "IGES", + "stl": "STL", + "obj": "OBJ", + } + + Part.export([compound], output_path) + FreeCAD.closeDocument(doc.Name) +``` + +--- + +## 7. Headless Invocation + +The `silorunner` binary shells out to Create (with silo-mod installed): + +```bash +# DAG extraction +create --console -e "from silo.runner import dag_extract; dag_extract('/tmp/job/part.kc', '/tmp/job/dag.json')" + +# Validation +create --console -e "from silo.runner import validate; validate('/tmp/job/part.kc', '/tmp/job/result.json')" + +# Export +create --console -e "from silo.runner import export; export('/tmp/job/part.kc', '/tmp/job/output.step', 'step')" +``` + +**Prerequisites:** The runner host must have: +- Headless Create installed (Kindred's fork of FreeCAD) +- silo-mod installed as a Create addon (so `from silo.runner import ...` works) +- No display server required -- `--console` mode is headless + +--- + +## 8. Validation Result Handling + +After a runner completes a `create-validate` job, it should: + +1. Read the result JSON. +2. Push updated validation states via `PUT /api/runner/jobs/{jobID}/dag`: + +```json +{ + "revision_number": 3, + "nodes": [ + {"node_key": "Sketch001", "node_type": "sketch", "validation_state": "clean", "properties_hash": "abc..."}, + {"node_key": "Pad001", "node_type": "pad", "validation_state": "failed", "properties_hash": "def..."} + ], + "edges": [ + {"source_key": "Sketch001", "target_key": "Pad001"} + ] +} +``` + +3. Complete the job via `POST /api/runner/jobs/{jobID}/complete` with the summary result. + +--- + +## 9. SSE Events + +Clients should listen for these events on `GET /api/events`: + +| Event | Payload | When | +|-------|---------|------| +| `dag.updated` | `{item_id, part_number, revision_number, node_count, edge_count}` | After any DAG sync | +| `dag.validated` | `{item_id, part_number, valid, failed_count}` | After validation completes | +| `job.created` | `{job_id, definition_name, trigger, item_id}` | Job auto-triggered or manually created | +| `job.claimed` | `{job_id, runner_id, runner}` | Runner claims a job | +| `job.progress` | `{job_id, progress, message}` | Runner reports progress | +| `job.completed` | `{job_id, runner_id}` | Job finishes successfully | +| `job.failed` | `{job_id, runner_id, error}` | Job fails | +| `job.cancelled` | `{job_id, cancelled_by}` | Job cancelled by user | + +--- + +## 10. Cross-Item Edges + +For assembly constraints that reference geometry in child parts (e.g. a mate constraint between two parts), use the `dag_cross_edges` table. These edges bridge the BOM DAG and the feature DAG. + +Cross-item edges are **not** included in the standard `PUT /dag` sync. They will be managed through a dedicated endpoint in a future iteration once the assembly constraint model in Create/silo-mod is finalized. + +For now, the DAG sync covers intra-item dependencies only. Assembly-level interference detection uses the BOM DAG (`relationships` table) combined with per-item feature DAGs.