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:

  1. 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 as u = 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.

  2. 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.

  3. 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)

CylinderParameters

origin, axis, radius

ConeParameters

apex, axis, half_angle

TorusParameters

center, axis, major_radius, minor_radius

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 defeats mypy --strict, and it gives no place to attach the cone-apex hazard note where the reader will meet it.

  • Give CurveSource a length() 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.