feat(reconcile): sub-solution merger with loop closure validation #29

Open
opened 2026-02-20 22:27:21 +00:00 by forbes · 0 comments
Owner

Summary

Implement reconciler.py — merges sub-solutions from all dispatched subproblems into a single SolveResult, validates kinematic loop closure, and applies minimal-movement preference heuristics.

Context

After the dispatcher solves each subproblem independently, the reconciler assembles the global solution. The critical challenge is loop closure: when a biconnected component was decomposed by cutting kinematic loops, the cut-constraint residuals must be checked. The reconciler also implements minimal-movement preference — when multiple valid solutions exist, pick the one closest to the initial configuration. This is the D-Cubed-style heuristic that OndselSolver completely lacks, and is a major source of "obscure solution" complaints.

Depends on: #28 (dispatcher — provides SubproblemResult for each subproblem)

Design

Reconciliation pipeline

  1. Collect placements — gather all part_id → Transform mappings from sub-results
    • For parts appearing in multiple subproblems (shared/articulation parts), verify consistency (should be identical within tolerance since dependencies propagate fixed placements)
  2. Build global SolveResult — merge all placements into a single list
  3. Loop closure check — for constraints that span subproblem boundaries (cut constraints in kinematic loops):
    • Evaluate constraint residual using the merged placements
    • If residual > tolerance → flag as requiring a closure pass
  4. Closure pass (if needed) — build a reduced SolveContext containing only the loop-closure constraints and the parts they connect, with all other parts grounded at their solved positions. Dispatch to the numerical backend for a targeted N-R solve using the current placements as warm start.
  5. Minimal-movement preference — when a sub-solver produces multiple valid solutions (e.g., two configurations for a 4-bar linkage), compare each against the warm-start placements from SolveContext.parts and pick the solution with minimal aggregate part displacement:
    cost = Σ ||p_solved.position - p_initial.position||² + λ · Σ angular_distance(q_solved, q_initial)²
    
  6. Aggregate diagnostics — merge ConstraintDiagnostic lists from all sub-results plus any closure diagnostics
  7. Compute global DOF — sum of per-subproblem DOF, adjusted for shared parts

API

class Reconciler:
    def merge(
        self,
        ctx: SolveContext,          # original full context (for warm-start placements)
        plan: SolvePlan,            # decomposition plan (for structure info)
        sub_results: DispatchResult # per-subproblem results
    ) -> SolveResult

    def _check_loop_closure(
        self,
        ctx: SolveContext,
        plan: SolvePlan,
        merged_placements: dict[str, Transform]
    ) -> list[str]  # constraint IDs with residual > tolerance

    def _apply_minimal_movement(
        self,
        candidates: list[dict[str, Transform]],
        initial: dict[str, Transform]
    ) -> dict[str, Transform]

Tolerance

Loop closure tolerance should match the solver's tolerance (default 1e-9 for position, 1e-7 for angular). These should be configurable but sensible by default.

Tasks

  • Implement Reconciler class
  • Placement collection and consistency verification for shared parts
  • Global SolveResult assembly from sub-results
  • Loop closure residual evaluation
  • Closure pass dispatch to numerical backend (warm-started N-R)
  • Minimal-movement preference heuristic
  • Diagnostic aggregation across all subproblems
  • Global DOF computation
  • Unit tests in tests/decomposition/test_reconciler.py:
    • Two independent subproblems → placements merged correctly
    • Shared articulation part → consistent placement verified
    • Intentionally bad loop closure → closure pass triggered and succeeds
    • Minimal-movement: two valid configurations → picks closer one
    • Partial failure propagation (one subproblem failed → overall status reflects it)
    • Diagnostics from multiple subproblems aggregated correctly

Acceptance criteria

  • merge() returns a valid SolveResult with all parts accounted for
  • Shared part placement consistency is verified (assertion on mismatch)
  • Loop closure violations trigger a targeted re-solve, not a full re-solve
  • Minimal-movement preference demonstrably picks the closer configuration
  • All diagnostics from sub-results are preserved in the final result
## Summary Implement `reconciler.py` — merges sub-solutions from all dispatched subproblems into a single `SolveResult`, validates kinematic loop closure, and applies minimal-movement preference heuristics. ## Context After the dispatcher solves each subproblem independently, the reconciler assembles the global solution. The critical challenge is **loop closure**: when a biconnected component was decomposed by cutting kinematic loops, the cut-constraint residuals must be checked. The reconciler also implements **minimal-movement preference** — when multiple valid solutions exist, pick the one closest to the initial configuration. This is the D-Cubed-style heuristic that OndselSolver completely lacks, and is a major source of "obscure solution" complaints. Depends on: #28 (dispatcher — provides SubproblemResult for each subproblem) ## Design ### Reconciliation pipeline 1. **Collect placements** — gather all `part_id → Transform` mappings from sub-results - For parts appearing in multiple subproblems (shared/articulation parts), verify consistency (should be identical within tolerance since dependencies propagate fixed placements) 2. **Build global SolveResult** — merge all placements into a single list 3. **Loop closure check** — for constraints that span subproblem boundaries (cut constraints in kinematic loops): - Evaluate constraint residual using the merged placements - If residual > tolerance → flag as requiring a closure pass 4. **Closure pass** (if needed) — build a reduced `SolveContext` containing only the loop-closure constraints and the parts they connect, with all other parts grounded at their solved positions. Dispatch to the numerical backend for a targeted N-R solve using the current placements as warm start. 5. **Minimal-movement preference** — when a sub-solver produces multiple valid solutions (e.g., two configurations for a 4-bar linkage), compare each against the warm-start placements from `SolveContext.parts` and pick the solution with minimal aggregate part displacement: ``` cost = Σ ||p_solved.position - p_initial.position||² + λ · Σ angular_distance(q_solved, q_initial)² ``` 6. **Aggregate diagnostics** — merge `ConstraintDiagnostic` lists from all sub-results plus any closure diagnostics 7. **Compute global DOF** — sum of per-subproblem DOF, adjusted for shared parts ### API ```python class Reconciler: def merge( self, ctx: SolveContext, # original full context (for warm-start placements) plan: SolvePlan, # decomposition plan (for structure info) sub_results: DispatchResult # per-subproblem results ) -> SolveResult def _check_loop_closure( self, ctx: SolveContext, plan: SolvePlan, merged_placements: dict[str, Transform] ) -> list[str] # constraint IDs with residual > tolerance def _apply_minimal_movement( self, candidates: list[dict[str, Transform]], initial: dict[str, Transform] ) -> dict[str, Transform] ``` ### Tolerance Loop closure tolerance should match the solver's tolerance (default 1e-9 for position, 1e-7 for angular). These should be configurable but sensible by default. ## Tasks - [ ] Implement `Reconciler` class - [ ] Placement collection and consistency verification for shared parts - [ ] Global `SolveResult` assembly from sub-results - [ ] Loop closure residual evaluation - [ ] Closure pass dispatch to numerical backend (warm-started N-R) - [ ] Minimal-movement preference heuristic - [ ] Diagnostic aggregation across all subproblems - [ ] Global DOF computation - [ ] Unit tests in `tests/decomposition/test_reconciler.py`: - Two independent subproblems → placements merged correctly - Shared articulation part → consistent placement verified - Intentionally bad loop closure → closure pass triggered and succeeds - Minimal-movement: two valid configurations → picks closer one - Partial failure propagation (one subproblem failed → overall status reflects it) - Diagnostics from multiple subproblems aggregated correctly ## Acceptance criteria - `merge()` returns a valid `SolveResult` with all parts accounted for - Shared part placement consistency is verified (assertion on mismatch) - Loop closure violations trigger a targeted re-solve, not a full re-solve - Minimal-movement preference demonstrably picks the closer configuration - All diagnostics from sub-results are preserved in the final result
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/solver#29