ADR-014 — Carry analytic surface parameters and exact arc length across the core/adapter contract¶
Status: Accepted
Date: 2026-08-30
Amends: PRD 12.3 (marked
[CHANGED v1→v2]there)Deciders: stakeholder, orchestrator, reviewer R-F1, implementer F1
Context¶
PRD 12.3 specified SurfaceSource as six methods: parametric_range, point_at,
first_derivatives, curvatures, is_on_face and outward_normal_at. CurveSource
was not specified at all; its shape was chosen by the implementer.
A review of the M1 skeleton found that two things the PRD requires by name are unreachable through those contracts.
Tiers A and B are written in terms the contract cannot express¶
PRD 6.2 opens Tier A with “given cylinder origin O, unit axis a, radius r…”, and
PRD 6.3 writes Tier B in terms of apex, axis and half_angle. None of origin,
axis, radius or half_angle is available through SurfaceSource. An implementer
had three options and all three were bad:
Recover them by sampling.
r = |P_v|,a = P_u.normalized(),O = point_at(0,0) - outward_normal_at(0,0) * r. This works, but only because Fusion parameterizes a cylinder asu = z/r, v = θ— which is the exact assumption PRD 6.2 tells the implementer not to make: “prefer the geometric form above — it is independent of Fusion’s parameterization and therefore portable per G6.” Deriving the geometric form from the parameterization defeats the reason the PRD asks for it.Widen the protocol independently. Correct, but with four Phase 2 agents each hitting the same gap, the contract gets renegotiated four times, in four shapes, after everything already imports it.
Route the parameters around the protocol in an ad-hoc tuple, which is how a boundary stops being a boundary.
Tier B is the worse case: recovering half_angle by differentiating sampled points,
when PRD 6.3 flags the apex as a hazard that “silently produces a template with the
wrong developed sweep angle”, invites precisely the failure the hazard names.
Tier C is told to prefer a method it cannot call¶
PRD 6.4 is explicit: “Arc length must be computed by integrating along the profile
curve, not by chord summation. Prefer CurveEvaluator3D.getLengthAtParameter,
which is exact for the underlying geometry.”
CurveSource offered stroke_points(tolerance_mm) and first_derivative(t).
Everything Tier C is told to prefer was unreachable, and the thing it is told not to do
was the easiest thing to reach for: Polyline3D.length() takes exactly what
stroke_points returns, and its own docstring admits it underestimates the true arc
length by the chordal deviation. The resulting template is short by an amount that is
systematic, smooth and small — the worst magnitude an error can have, because it
survives eyeballing and shows up on the metal.
Decision¶
1. SurfaceSource gains analytic_parameters() -> AnalyticSurface | None.
AnalyticSurface is a frozen union in core/types.py:
Type |
Fields (millimeters, radians) |
|---|---|
|
|
|
|
|
|
A NURBS or otherwise non-analytic face returns None. That is an explicit, typed
answer — “this face has no closed form” is a fact a tier branches on, not an omission it
may overlook.
2. ConeParameters stores the apex, never Fusion’s Cone.origin.
This is not a style preference. CLAUDE.md section 6 and PRD 6.3 both record that
Cone.origin is not the apex and Cone.radius is the radius at the origin; the
apex is origin - (radius / tan α) · axis. Storing the origin here would re-expose
every downstream consumer to that hazard, once per consumer. Computing it once at the
adapter boundary means the trap is entered at most once in the whole program, and a
consumer of the type cannot re-enter it because the raw value has no route across the
boundary. Do not add an origin field.
3. CurveSource gains length_between(t_start, t_end) -> float.
Exact arc length in millimeters, non-negative, symmetric in its arguments. Implemented
over getLengthAtParameter in the adapter and in closed form in the fixtures. Its
docstring quotes PRD 6.4’s prohibition and names Polyline3D.length() as the thing not
to reach for.
Consequences¶
The metric-first architecture is untouched. Tier E needs nothing from
analytic_parameters and works from first_derivatives alone (PRD 6.0, ADR-004), so
None is always a workable answer rather than a dead end. ADR-004 and ADR-005 both
stand: classification remains a statement about the metric, never about the type name
Fusion reports, and the presence of a descriptor is not permission to skip that
classification.
A descriptor is an assertion, and asserting it wrongly is a PRD 6.5 / 7.12 defect.
Returning TorusParameters claims the minor radius is genuinely constant along the
face, which is exactly the constant-radius gate PRD 6.5 makes mandatory before an r/R
strain bound may be quoted. A face that merely looks toroidal must return None: the
reference model has one running from 0.03 % to 60.8 % ovality along its length, and a
confident wrong number is worse than an honest measured one.
This is a MINOR version change. CLAUDE.md section 10 makes the core/ interface
public API. Widening a Protocol breaks any existing implementation that does not grow
the new methods; at the time of this decision the only implementations are the test
fixtures, which were updated in the same change.
Cost of not deciding it now: four agents each inventing a different answer to the same missing method, after everything already imports the contract. That is the specific expensive failure this decision was raised to prevent, and it was nearly free to settle before Phase 2 began.
What the adapter now owes. adapter/surface.py must compute the cone apex once, at
the boundary, and must apply the constant-radius gate before returning
TorusParameters. adapter/curve.py must implement length_between over
getLengthAtParameter rather than by integrating first_derivative numerically, and
must convert the parameter domain per PRD 7.6. Both are covered by the L4 live smoke
suite (PRD 13.4).
Alternatives rejected¶
Leave the contract as PRD 12.3 wrote it and recover parameters by sampling. Rejected: it is the option PRD 6.2 explicitly warns against, it re-derives the geometric form from the parameterization the geometric form exists to be independent of, and it puts the cone-apex hazard in front of every tier author.
Let each Phase 2 agent widen the protocol as it needs to. Rejected: four renegotiations of one boundary, arriving after the boundary is already imported.
A single
analytic_parameters() -> dict[str, float] | None. Rejected: an untyped bag defeatsmypy --strict, and it gives no place to attach the cone-apex hazard note where the reader will meet it.Give
CurveSourcealength()for the whole curve only. Rejected: Tier C needs arc length between two parameters to place a datum, and a whole-curve accessor would leave chord summation as the only way to get it.