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