- STATUS.md: migration count 18→23, endpoint count 86→~140, add approval workflows, solver service, workstations, edit sessions, SSE targeted delivery rows, update test file count 9→31, add migrations 019-023 - MODULES.md: add solver and sessions to registry, dependencies, endpoint mappings (sections 3.11, 3.12), discovery response, admin settings, config YAML, and future considerations - CONFIGURATION.md: add Approval Workflows, Solver, and Modules config sections, add SILO_SOLVER_DEFAULT env var - ROADMAP.md: mark Job Queue Complete (Tier 0), Audit Trail Complete (Tier 1), Approval/ECO Complete (Tier 4), update Workflow Engine tasks, add Recently Completed section, update counts, resolve job queue question - GAP_ANALYSIS.md: mark approval workflow Implemented, locking Partial, update workflow comparison (C.2), update check-in/check-out to Partial, task scheduler to Full, update endpoint counts, rewrite Appendix A - INSTALL.md: add MODULES.md, WORKERS.md, SOLVER.md to Further Reading - WORKERS.md: status Draft→Implemented - SOLVER.md: add spec doc, mark Phase 3b as complete
27 KiB
Solver Service Specification
Status: Phase 3b Implemented (server endpoints, job definitions, result cache)
Last Updated: 2026-03-01
Depends on: KCSolve Phase 1 (PR #297), Phase 2 (PR #298)
Prerequisite infrastructure: Job queue, runner system, and SSE broadcasting are fully implemented (see WORKERS.md, migration 015_jobs_runners.sql, cmd/silorunner/).
1. Overview
The solver service extends Silo's job queue system with assembly constraint solving capabilities. It enables server-side solving of assemblies stored in Silo, with results streamed back to clients in real time via SSE.
This specification describes how the existing KCSolve client-side API (C++ library + pybind11 kcsolve module) integrates with Silo's worker infrastructure to provide headless, asynchronous constraint solving.
1.1 Goals
- Offload solving -- Move heavy solve operations off the user's machine to server workers.
- Batch validation -- Automatically validate assemblies on commit (e.g. check for over-constrained systems).
- Solver selection -- Allow the server to run different solvers than the client (e.g. a more thorough solver for validation, a fast one for interactive editing).
- Standalone execution -- Solver workers can run without a full FreeCAD installation, using just the
kcsolvePython module and the.kcfile.
1.2 Non-Goals
- Interactive drag -- Real-time drag solving stays client-side (latency-sensitive).
- Geometry processing -- Workers don't compute geometry; they receive pre-extracted constraint graphs.
- Solver development -- Writing new solver backends is out of scope; this spec covers the transport and execution layer.
2. Architecture
┌─────────────────────┐
│ Kindred Create │
│ (FreeCAD client) │
└───────┬──────────────┘
│ 1. POST /api/solver/jobs
│ (SolveContext JSON)
│
│ 4. GET /api/events (SSE)
│ job.progress, job.completed
▼
┌─────────────────────┐
│ Silo Server │
│ (silod) │
│ │
│ solver module │
│ REST + SSE + queue │
└───────┬──────────────┘
│ 2. POST /api/runner/claim
│ 3. POST /api/runner/jobs/{id}/complete
▼
┌─────────────────────┐
│ Solver Runner │
│ (silorunner) │
│ │
│ kcsolve module │
│ OndselAdapter │
│ Python solvers │
└─────────────────────┘
2.1 Components
| Component | Role | Deployment |
|---|---|---|
| Silo server | Job queue management, REST API, SSE broadcast, result storage | Existing silod binary (jobs module, migration 015) |
| Solver runner | Claims solver jobs, executes kcsolve, reports results |
Existing silorunner binary (cmd/silorunner/) with solver tag |
| kcsolve module | Python/C++ solver library (Phase 1+2) | Installed on runner nodes |
| Create client | Submits jobs, receives results via SSE | Existing FreeCAD client |
2.2 Module Registration
The solver service is a Silo module with ID solver, gated behind the existing module system:
# config.yaml
modules:
solver:
enabled: true
It depends on the jobs module being enabled. All solver endpoints return 404 with {"error": "module not enabled"} when disabled.
3. Data Model
3.1 SolveContext JSON Schema
The SolveContext is the input to a solve operation. Currently it exists only as a C++ struct and pybind11 binding with no serialization. Phase 3 adds JSON serialization to enable server transport.
{
"api_version": 1,
"parts": [
{
"id": "Part001",
"placement": {
"position": [0.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"mass": 1.0,
"grounded": true
},
{
"id": "Part002",
"placement": {
"position": [100.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"mass": 1.0,
"grounded": false
}
],
"constraints": [
{
"id": "Joint001",
"part_i": "Part001",
"marker_i": {
"position": [50.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"part_j": "Part002",
"marker_j": {
"position": [0.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"type": "Revolute",
"params": [],
"limits": [],
"activated": true
}
],
"motions": [],
"simulation": null,
"bundle_fixed": false
}
Field reference: See KCSolve Python API for full field documentation. The JSON schema maps 1:1 to the Python/C++ types.
Enum serialization: Enums serialize as strings matching their Python names (e.g. "Revolute", "Success", "Redundant").
Transform shorthand: The placement and marker_* fields use the Transform struct: position is [x, y, z], quaternion is [w, x, y, z].
Constraint.Limit:
{
"kind": "RotationMin",
"value": -1.5708,
"tolerance": 1e-9
}
MotionDef:
{
"kind": "Rotational",
"joint_id": "Joint001",
"marker_i": "",
"marker_j": "",
"rotation_expr": "2*pi*t",
"translation_expr": ""
}
SimulationParams:
{
"t_start": 0.0,
"t_end": 2.0,
"h_out": 0.04,
"h_min": 1e-9,
"h_max": 1.0,
"error_tol": 1e-6
}
3.2 SolveResult JSON Schema
{
"status": "Success",
"placements": [
{
"id": "Part002",
"placement": {
"position": [50.0, 0.0, 0.0],
"quaternion": [0.707, 0.0, 0.707, 0.0]
}
}
],
"dof": 1,
"diagnostics": [
{
"constraint_id": "Joint003",
"kind": "Redundant",
"detail": "6 DOF removed by Joint003 are already constrained"
}
],
"num_frames": 0
}
3.3 Solver Job Record
Solver jobs are stored in the existing jobs table. The solver-specific data is in the args and result JSONB columns.
Job args (input):
{
"solver": "ondsel",
"operation": "solve",
"context": { /* SolveContext JSON */ },
"item_part_number": "ASM-001",
"revision_number": 3
}
Operation types:
| Operation | Description | Requires simulation? |
|---|---|---|
solve |
Static equilibrium solve | No |
diagnose |
Constraint analysis only (no placement update) | No |
kinematic |
Time-domain kinematic simulation | Yes |
Job result (output):
{
"result": { /* SolveResult JSON */ },
"solver_name": "OndselSolver (Lagrangian)",
"solver_version": "1.0",
"solve_time_ms": 127.4
}
4. REST API
All endpoints are prefixed with /api/solver/ and gated behind RequireModule("solver").
4.1 Submit Solve Job
POST /api/solver/jobs
Authorization: Bearer silo_...
Content-Type: application/json
{
"solver": "ondsel",
"operation": "solve",
"context": { /* SolveContext */ },
"priority": 50
}
Optional fields:
| Field | Type | Default | Description |
|---|---|---|---|
solver |
string | "" (default solver) |
Solver name from registry |
operation |
string | "solve" |
solve, diagnose, or kinematic |
context |
object | required | SolveContext JSON |
priority |
int | 50 |
Lower = higher priority |
item_part_number |
string | null |
Silo item reference (for result association) |
revision_number |
int | null |
Revision that generated this context |
callback_url |
string | null |
Webhook URL for completion notification |
Response 201 Created:
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"created_at": "2026-02-19T18:30:00Z"
}
Error responses:
| Code | Condition |
|---|---|
400 |
Invalid SolveContext (missing required fields, unknown enum values) |
401 |
Not authenticated |
404 |
Module not enabled |
422 |
Unknown solver name, invalid operation |
4.2 Get Job Status
GET /api/solver/jobs/{jobID}
Response 200 OK:
{
"job_id": "550e8400-...",
"status": "completed",
"operation": "solve",
"solver": "ondsel",
"priority": 50,
"item_part_number": "ASM-001",
"revision_number": 3,
"runner_id": "runner-01",
"runner_name": "solver-worker-01",
"created_at": "2026-02-19T18:30:00Z",
"claimed_at": "2026-02-19T18:30:01Z",
"completed_at": "2026-02-19T18:30:02Z",
"result": {
"result": { /* SolveResult */ },
"solver_name": "OndselSolver (Lagrangian)",
"solve_time_ms": 127.4
}
}
4.3 List Solver Jobs
GET /api/solver/jobs?status=completed&item=ASM-001&limit=20&offset=0
Query parameters:
| Param | Type | Description |
|---|---|---|
status |
string | Filter by status: pending, claimed, running, completed, failed |
item |
string | Filter by item part number |
operation |
string | Filter by operation type |
solver |
string | Filter by solver name |
limit |
int | Page size (default 20, max 100) |
offset |
int | Pagination offset |
Response 200 OK:
{
"jobs": [ /* array of job objects */ ],
"total": 42,
"limit": 20,
"offset": 0
}
4.4 Cancel Job
POST /api/solver/jobs/{jobID}/cancel
Only pending and claimed jobs can be cancelled. Running jobs must complete or time out.
Response 200 OK:
{
"job_id": "550e8400-...",
"status": "cancelled"
}
4.5 Get Solver Registry
GET /api/solver/solvers
Returns available solvers on registered runners. Runners report their solver capabilities during heartbeat.
Response 200 OK:
{
"solvers": [
{
"name": "ondsel",
"display_name": "OndselSolver (Lagrangian)",
"deterministic": true,
"supported_joints": [
"Coincident", "Fixed", "Revolute", "Cylindrical",
"Slider", "Ball", "Screw", "Gear", "RackPinion",
"Parallel", "Perpendicular", "Angle", "Planar",
"Concentric", "PointOnLine", "PointInPlane",
"LineInPlane", "Tangent", "DistancePointPoint",
"DistanceCylSph", "Universal"
],
"runner_count": 2
}
],
"default_solver": "ondsel"
}
5. Server-Sent Events
Solver jobs emit events on the existing /api/events SSE stream.
5.1 Event Types
Solver jobs use the existing job.* SSE event prefix (see WORKERS.md). Clients filter on definition_name to identify solver-specific events.
| Event | Payload | When |
|---|---|---|
job.created |
{job_id, definition_name, trigger, item_id} |
Job submitted |
job.claimed |
{job_id, runner_id, runner} |
Runner claims work |
job.progress |
{job_id, progress, message} |
Progress update (0-100) |
job.completed |
{job_id, runner_id} |
Job succeeded |
job.failed |
{job_id, runner_id, error} |
Job failed |
5.2 Example Stream
event: job.created
data: {"job_id":"abc-123","definition_name":"assembly-solve","trigger":"manual","item_id":"uuid-..."}
event: job.claimed
data: {"job_id":"abc-123","runner_id":"r1","runner":"solver-worker-01"}
event: job.progress
data: {"job_id":"abc-123","progress":50,"message":"Building constraint system..."}
event: job.completed
data: {"job_id":"abc-123","runner_id":"r1"}
5.3 Client Integration
The Create client subscribes to the SSE stream and updates the Assembly workbench UI:
- Silo viewport widget shows job status indicator (pending/running/done/failed)
- On
job.completed(wheredefinition_namestarts withassembly-), the client fetches the full result viaGET /api/jobs/{id}and applies placements - On
job.failed, the client shows the error in the report panel - Diagnostic results (redundant/conflicting constraints) surface in the constraint tree
6. Runner Integration
6.1 Runner Requirements
Solver runners are standard silorunner instances (see cmd/silorunner/main.go) registered with the solver tag. The existing runner binary already handles the full job lifecycle (claim, start, progress, complete/fail, log, DAG sync). Solver support requires adding solver-run, solver-diagnose, and solver-kinematic to the runner's command dispatch (currently handles create-validate, create-export, create-dag-extract, create-thumbnail).
Additional requirements on the runner host:
- Python 3.11+ with
kcsolvemodule installed libKCSolve.soand solver backend libraries (e.g.libOndselSolver.so)- Network access to the Silo server
No FreeCAD installation is required. The runner operates on pre-extracted SolveContext JSON.
6.2 Runner Registration
# Register a solver runner (admin)
curl -X POST https://silo.example.com/api/runners \
-H "Authorization: Bearer admin_token" \
-d '{"name":"solver-01","tags":["solver"]}'
# Response includes one-time token
{"id":"uuid","token":"silo_runner_xyz..."}
6.3 Runner Heartbeat and Capabilities
The existing heartbeat endpoint (POST /api/runner/heartbeat) takes no body — it updates last_heartbeat on every authenticated request via the RequireRunnerAuth middleware. Runners that go 90 seconds without a request are marked offline by the background sweeper.
Solver capabilities are reported via the runner's metadata JSONB field, set at registration time:
curl -X POST https://silo.example.com/api/runners \
-H "Authorization: Bearer admin_token" \
-d '{
"name": "solver-01",
"tags": ["solver"],
"metadata": {
"solvers": ["ondsel"],
"api_version": 1,
"python_version": "3.11.11"
}
}'
Future enhancement: The heartbeat endpoint could be extended to accept an optional body for dynamic capability updates, but currently capabilities are static per registration.
6.4 Runner Execution Flow
#!/usr/bin/env python3
"""Solver runner entry point."""
import json
import kcsolve
def execute_solve_job(args: dict) -> dict:
"""Execute a solver job from parsed args."""
solver_name = args.get("solver", "")
operation = args.get("operation", "solve")
ctx_dict = args["context"]
# Deserialize SolveContext from JSON
ctx = kcsolve.SolveContext.from_dict(ctx_dict)
# Load solver
solver = kcsolve.load(solver_name)
if solver is None:
raise ValueError(f"Unknown solver: {solver_name!r}")
# Execute operation
if operation == "solve":
result = solver.solve(ctx)
elif operation == "diagnose":
diags = solver.diagnose(ctx)
result = kcsolve.SolveResult()
result.diagnostics = diags
elif operation == "kinematic":
result = solver.run_kinematic(ctx)
else:
raise ValueError(f"Unknown operation: {operation!r}")
# Serialize result
return {
"result": result.to_dict(),
"solver_name": solver.name(),
"solver_version": "1.0",
}
6.5 Standalone Process Mode
For minimal deployments, the runner can invoke a standalone solver process:
echo '{"solver":"ondsel","operation":"solve","context":{...}}' | \
python3 -m kcsolve.runner
The kcsolve.runner module reads JSON from stdin, executes the solve, and writes the result JSON to stdout. Exit code 0 = success, non-zero = failure with error JSON on stderr.
7. Job Definitions
7.1 Manual Solve Job
Triggered by the client when the user requests a server-side solve.
Note: The
compute.typeusescustombecause the valid types ininternal/jobdef/jobdef.goare:validate,rebuild,diff,export,custom. Solver commands are dispatched by the runner based on thecommandfield.
job:
name: assembly-solve
version: 1
description: "Solve assembly constraints on server"
trigger:
type: manual
scope:
type: assembly
compute:
type: custom
command: solver-run
runner:
tags: [solver]
timeout: 300
max_retries: 1
priority: 50
7.2 Commit-Time Validation
Automatically validates assembly constraints when a new revision is committed:
job:
name: assembly-validate
version: 1
description: "Validate assembly constraints on commit"
trigger:
type: revision_created
filter:
item_type: assembly
scope:
type: assembly
compute:
type: custom
command: solver-diagnose
args:
operation: diagnose
runner:
tags: [solver]
timeout: 120
max_retries: 2
priority: 75
7.3 Kinematic Simulation
Server-side kinematic simulation for assemblies with motion definitions:
job:
name: assembly-kinematic
version: 1
description: "Run kinematic simulation"
trigger:
type: manual
scope:
type: assembly
compute:
type: custom
command: solver-kinematic
args:
operation: kinematic
runner:
tags: [solver]
timeout: 1800
max_retries: 0
priority: 100
8. SolveContext Extraction
When a solver job is triggered by a revision commit (rather than a direct context submission), the server or runner must extract a SolveContext from the .kc file.
8.1 Extraction via Headless Create
For full-fidelity extraction that handles geometry classification:
create --console -e "
import kcsolve_extract
kcsolve_extract.extract_and_solve('input.kc', 'output.json', solver='ondsel')
"
This requires a full Create installation on the runner and uses the Assembly module's existing adapter layer to build SolveContext from document objects.
8.2 Extraction from .kc Silo Directory
For lightweight extraction without FreeCAD, the constraint graph can be stored in the .kc archive's silo/ directory during commit:
silo/solver/context.json # Pre-extracted SolveContext
silo/solver/result.json # Last solve result (if any)
The client extracts the SolveContext locally before committing the .kc file. The server reads it from the archive, avoiding the need for geometry processing on the runner.
Commit-time packing (client side):
# In the Assembly workbench commit hook:
ctx = assembly_object.build_solve_context()
kc_archive.write("silo/solver/context.json", ctx.to_json())
Runner-side extraction:
import zipfile, json
with zipfile.ZipFile("assembly.kc") as zf:
ctx_json = json.loads(zf.read("silo/solver/context.json"))
9. Database Schema
9.1 Migration
The solver module uses the existing jobs table. One new table is added for result caching:
-- Migration: 021_solver_results.sql
CREATE TABLE solver_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL,
job_id UUID REFERENCES jobs(id) ON DELETE SET NULL,
operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic'
solver_name TEXT NOT NULL,
status TEXT NOT NULL, -- SolveStatus string
dof INTEGER,
diagnostics JSONB DEFAULT '[]',
placements JSONB DEFAULT '[]',
num_frames INTEGER DEFAULT 0,
solve_time_ms DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(item_id, revision_number, operation)
);
CREATE INDEX idx_solver_results_item ON solver_results(item_id);
CREATE INDEX idx_solver_results_status ON solver_results(status);
The UNIQUE(item_id, revision_number, operation) constraint means each revision has at most one result per operation type. Re-running overwrites the previous result.
9.2 Result Association
When a solver job completes, the server:
- Stores the full result in the
jobs.resultJSONB column (standard job result) - Upserts a row in
solver_resultsfor quick lookup by item/revision - Broadcasts
job.completedSSE event
10. Configuration
10.1 Server Config
# config.yaml
modules:
solver:
enabled: true
default_solver: "ondsel"
max_context_size_mb: 10 # Reject oversized SolveContext payloads
default_timeout: 300 # Default job timeout (seconds)
auto_diagnose_on_commit: true # Auto-submit diagnose job on revision commit
10.2 Environment Variables
| Variable | Description |
|---|---|
SILO_SOLVER_ENABLED |
Override module enabled state |
SILO_SOLVER_DEFAULT |
Default solver name |
10.3 Runner Config
# runner.yaml
server_url: https://silo.example.com
token: silo_runner_xyz...
tags: [solver]
solver:
kcsolve_path: /opt/create/lib # LD_LIBRARY_PATH for kcsolve.so
python: /opt/create/bin/python3
max_concurrent: 2 # Parallel job slots per runner
11. Security
11.1 Authentication
All solver endpoints use the existing Silo authentication:
- User endpoints (
/api/solver/jobs): Session or API token, requiresviewerrole to read,editorrole to submit - Runner endpoints (
/api/runner/...): Runner token authentication (existing)
11.2 Input Validation
The server validates SolveContext JSON before queuing:
- Maximum payload size (configurable, default 10 MB)
- Required fields present (
parts,constraints) - Enum values are valid strings
- Transform arrays have correct length (position: 3, quaternion: 4)
- No duplicate part or constraint IDs
11.3 Runner Isolation
Solver runners execute untrusted constraint data. Mitigations:
- Runners should run in containers or sandboxed environments
- Python solver registration (
kcsolve.register_solver()) is disabled in runner mode - Solver execution has a configurable timeout (killed on expiry)
- Result size is bounded (large kinematic simulations are truncated)
12. Client SDK
12.1 Python Client
The existing silo-client package is extended with solver methods:
from silo_client import SiloClient
client = SiloClient("https://silo.example.com", token="silo_...")
# Submit a solve job
import kcsolve
ctx = kcsolve.SolveContext()
# ... build context ...
job = client.solver.submit(ctx.to_dict(), solver="ondsel")
print(job.id, job.status) # "pending"
# Poll for completion
result = client.solver.wait(job.id, timeout=60)
print(result.status) # "Success"
# Or use SSE for real-time updates
for event in client.solver.stream(job.id):
print(event.type, event.data)
# Query results for an item
results = client.solver.results("ASM-001")
12.2 Create Workbench Integration
The Assembly workbench adds a "Solve on Server" command:
# CommandSolveOnServer.py (sketch)
def activated(self):
assembly = get_active_assembly()
ctx = assembly.build_solve_context()
# Submit to Silo
from silo_client import get_client
client = get_client()
job = client.solver.submit(ctx.to_dict())
# Subscribe to SSE for updates
self.watch_job(job.id)
def on_solver_completed(self, job_id, result):
# Apply placements back to assembly
assembly = get_active_assembly()
for pr in result["placements"]:
assembly.set_part_placement(pr["id"], pr["placement"])
assembly.recompute()
13. Implementation Plan
Phase 3a: JSON Serialization
Add to_dict() / from_dict() methods to all KCSolve types in the pybind11 module.
Files to modify:
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp-- add dict conversion methods
Verification: ctx.to_dict() round-trips through SolveContext.from_dict().
Phase 3b: Server Endpoints -- COMPLETE
Add the solver module to the Silo server. This builds on the existing job queue infrastructure (migration 015_jobs_runners.sql, internal/db/jobs.go, internal/api/job_handlers.go, internal/api/runner_handlers.go).
Implemented files:
internal/api/solver_handlers.go-- REST endpoint handlers (solver-specific convenience layer over existing/api/jobs)internal/db/migrations/021_solver_results.sql-- Database migration for result caching table- Module registered as
solverininternal/modules/modules.gowithjobsdependency
Phase 3c: Runner Support
Add solver command handlers to the existing silorunner binary (cmd/silorunner/main.go). The runner already implements the full job lifecycle (claim, start, progress, complete/fail). This phase adds solver-run, solver-diagnose, and solver-kinematic to the executeJob switch statement.
Files to modify:
cmd/silorunner/main.go-- Add solver command dispatch casessrc/Mod/Assembly/Solver/bindings/runner.py--kcsolve.runnerPython entry point (invoked by silorunner via subprocess)
Phase 3d: .kc Context Packing
Pack SolveContext into .kc archives on commit.
Files to modify:
mods/silo/freecad/silo_origin.py-- Hook into commit to pack solver context
Phase 3e: Client Integration
Add "Solve on Server" command to the Assembly workbench.
Files to modify:
mods/silo/freecad/-- Solver client methodssrc/Mod/Assembly/-- Server solve command
14. Open Questions
-
Context size limits -- Large assemblies may produce multi-MB SolveContext JSON. Should we compress (gzip) or use a binary format (msgpack)?
-
Result persistence -- How long should solver results be retained? Per-revision (overwritten on next commit) or historical (keep all)?
-
Kinematic frame storage -- Kinematic simulations can produce thousands of frames. Store all frames in JSONB, or write to a separate file and reference it?
-
Multi-solver comparison -- Should the API support running the same context through multiple solvers and comparing results? Useful for Phase 4 (second solver validation).
-
Webhook notifications -- The
callback_urlfield allows external integrations (e.g. CI). What authentication should the webhook use?
15. References
- KCSolve Architecture
- KCSolve Python API Reference
- INTER_SOLVER.md -- Full pluggable solver spec
- WORKERS.md -- Worker/runner job system
- SPECIFICATION.md -- Silo server specification
- MODULES.md -- Module system