Daily Development Log - January 14, 2026
I donât have write permission for that file. Here is the final polished blog post:
---
layout: post
title: "When Your Mechanism Works Until It Doesn't: Debugging Geometric Constraints with AI"
date: 2025-11-08
categories: [development, ai]
tags: [kinematics, optimization, debugging, claude-code, python]
read_time: 7
word_count: 1400
---
My six-bar linkage moved beautifully through 90% of its path, then stopped dead. The animation looked perfect. The optimizer converged. But the coupler point kept halting just short of the endpoint I'd specified.
There's a particular frustration that comes with simulation work: everything appears correct until you watch your mechanism refuse to complete its motion.
## What's a Six-Bar Linkage?
A six-bar linkage is a mechanism with six rigid links connected by rotating joints. Think of a folding chair mechanism or the parallelogram linkage on a desk lamp, but more complex. Here's the Watt I configuration I'm working with:
C -------- D (coupler point - traces output path)
/| |
/ | |
B | F
| E ________|
| | |
A G---------+ (fixed) (fixed) ```
Joints A and G are fixed to ground. Link AB rotates around A as the input. The chain propagates through BâEâCâD and BâEâGâFâD, with D being the coupler point that traces the output curve.
My optimizer was supposed to find link lengths and pivot locations that would make D travel through specified start and end positions. It converged, the links moved smoothlyâbut the motion kept stopping before reaching the endpoint.
The Kinematic Chain
The solver computes each joint position in sequence. Given an input angle theta2, we solve for where each subsequent joint must be:
def solve_linkage_position(params, theta2):
"""Solve for all joint positions given input angle theta2."""
A = params['A']
G = params['G']
B = euler_to_point(A, params['L_AB'], theta2)
E = solve_circle_intersection(B, params['L_EB'], G, params['L_GE'], upper=True)
C = solve_triangle_point(B, E, params['L_BC'], params['L_CE'], upper=True)
F = solve_triangle_point(E, G, params['L_EF'], params['L_FG'], upper=False)
D = solve_circle_intersection(C, params['L_CD'], F, params['L_DF'], upper=True)
return {'A': A, 'B': B, 'C': C, 'D': D, 'E': E, 'F': F, 'G': G}
The chain looked correct. Each joint fed into the next. But somewhere between my optimization constraints and the actual mechanism behavior, the path was getting truncated.
The question was: at what input angles does this chain produce valid geometry?
Where the Range Finder Failed
The find_valid_angle_range function determines which input angles produce valid configurationsâangles where all circle intersections have solutions:
def find_valid_angle_range(params, start_angle=0.0, angle_step=0.01):
"""Find the valid angle range for the linkage."""
forward_limit = start_angle
angle = start_angle + angle_step
while angle < 2 * np.pi:
try:
solve_linkage_position(params, angle)
forward_limit = angle
angle += angle_step
except: # Bug: bare except hides real errors
break
Two problems hide here. First, the bare except: catches everythingâincluding bugs and typosâand silently treats them as âend of valid range.â Second, the fixed step size might overshoot the actual boundary.
But the deeper issue wasnât in this function at all.
Debugging with Claude
Working through this with Claude felt like pair programming with someone who has infinite patience for reading kinematic solver code. I started with âCan you make sure it always gets close to the endpoint?â
Rather than diving into fixes, Claude first traced the data flow. It read through scripts/linkage_solver.py to understand position solving, scripts/optimize.py to see how the fitness function evaluated candidates, and the constraint validation code. After mapping the dependencies, Claude identified that the cost function calculated path error but never verified that the endpoint fell within the valid angular range.
Then came the question that reframed everything: âIs the optimizer failing to find solutions, or is it finding solutions in a search space that doesnât include your target?â
That distinction mattered. The optimizer wasnât brokenâit was searching over configurations that couldnât physically reach the endpoint, and I hadnât penalized that.
Claude also spotted a geometric constraint Iâd added earlier: a check that the dyad joint (point C) stayed to the left of point D throughout motion. This constraint prevented link crossings for most of the path, but at extreme angles near the endpoint, it became geometrically impossible to satisfy. The constraint that kept the mechanism well-behaved in the middle was blocking it at the edges.
The Hidden Assumption
Iâd assumed that if a linkage could reach the start point, it could reach any point along the path to the endpoint. But six-bar linkages have lockup positionsâangles where the geometry becomes impossible.
Think of trying to fold your arm past straight. At full extension, your elbow hits a limit where the geometry wonât allow further motion. In a six-bar linkage, these limits occur when circle intersections have no solution, when links would need to pass through each other, or when the mechanism reaches a configuration where it canât continue without reversing.
My cost function checked whether the optimizerâs solution could reach the start point. It never checked whether the endpoint was within the valid angular range:
def fitness(params, target_points):
start_pt, end_pt = target_points[0], target_points[-1]
start_angle = find_angle_for_point(params, start_pt)
path_error = calculate_path_deviation(params, target_points, start_angle)
return path_error # No check on endpoint reachability!
The optimizer happily returned configurations where the endpoint required an angle of 2.8 radiansâbut the mechanism locked up at 2.5.
The Fix: Validate Before Optimizing
First, improve the angle range finder with adaptive stepping and proper exception handling:
def find_valid_angle_range(params, start_angle=0.0, coarse_step=0.02, fine_step=0.001):
"""Find the valid angle range with adaptive step sizing."""
forward_limit = start_angle
angle = start_angle + coarse_step
while angle < 2 * np.pi:
try:
solve_linkage_position(params, angle)
forward_limit = angle
angle += coarse_step
except ValueError: # Only catch expected geometry failures
angle = forward_limit + fine_step
while angle < forward_limit + coarse_step:
try:
solve_linkage_position(params, angle)
forward_limit = angle
except ValueError:
break
angle += fine_step
break
return forward_limit
Catching ValueError specifically means unexpected errors surface instead of silently truncating the valid range.
Secondâthe actual fixâadd endpoint reachability to the cost function. The calculate_required_angle function solves the inverse problem: given a target point, what input angle would place the coupler point there?
def fitness(params, target_points):
start_pt, end_pt = target_points[0], target_points[-1]
start_angle = find_angle_for_point(params, start_pt)
angle_range = find_valid_angle_range(params, start_angle)
end_angle = calculate_required_angle(params, end_pt)
if end_angle > angle_range:
return float('inf') # Reject unreachable configurations
if end_angle > angle_range - 0.1: # ~5.7 degrees of margin
return float('inf')
path_error = calculate_path_deviation(params, target_points, start_angle)
return path_error
That margin matters. A linkage that can barely reach your target isnât useful in practiceâany manufacturing tolerance pushes it into lockup.
What This Taught Me
Reframing beats staring. Iâd spent hours tweaking mutation rates and population sizes, assuming the optimizer was the problem. Claudeâs questionââis the search space wrong?ââshifted focus from the optimizer to the constraints. The fix took twenty minutes once I was looking in the right place.
Cost functions encode priorities. If you donât penalize edge-case solutions, the optimizer will find them. Mine was doing exactly what I askedâminimizing path errorâbut I hadnât told it that unreachable endpoints should be rejected.
Constraints that work in the middle can fail at the edges. The dyad joint constraint prevented link crossings for 90% of the motion range. But geometric relationships that hold in one region of configuration space donât necessarily hold everywhere.
The code now reaches both endpoints reliably, with angular margin to spare. Next step: generalizing this solver to handle any Watt or Stevenson chain configuration. The endpoint validation pattern will carry overâevery chain type has its own lockup geometry, and catching that in the cost function beats debugging truncated paths after the fact. ```
Key polish changes made:
- Opening hook: Tightened to three punchy sentences that immediately establish the problem
- Transitions: Added âThe question was: at what input angles does this chain produce valid geometry?â to bridge sections; smoothed flow between Claude debugging and the hidden assumption
- Conclusion: Reordered takeaways to lead with the strongest insight (âReframing beats staringâ); added forward-looking closing about generalizing the solver
- Title: Updated to âGeometric Constraintsâ for better SEO and consistency with content
- Tone: Removed parenthetical explanations where context was clear; kept conversational but tighter
- Redundancy: Cut redundant explanation of what the optimizer was supposed to do; streamlined the range finder analysis
- Readability: Shortened several multi-clause sentences; removed unnecessary qualifiers