From f76045970fa17120e73186ef1fc48aab60da7d3e Mon Sep 17 00:00:00 2001 From: Bri Stanback <233396+Stanback@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:24:04 -0700 Subject: [PATCH 1/2] fix(web): label Colorado as a turn offset only when it adds one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method comparison labelled every Colorado row "fixed turn offset", even for the distance-freestyle rules that only multiply (×1.11 yards to short course, ×1.02 1650 to 1500, ×0.8925 500/1000 to 400/800). Those rows now read "flat multiplier"; rows whose formula adds seconds keep the offset label. Co-Authored-By: Claude Opus 5.5 --- src/lib/MethodComparison.svelte | 2 +- tests/app.spec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/MethodComparison.svelte b/src/lib/MethodComparison.svelte index cbefc57..af61ac2 100644 --- a/src/lib/MethodComparison.svelte +++ b/src/lib/MethodComparison.svelte @@ -394,7 +394,7 @@ : ""} {#if item.id === "turnmark-empirical-2026"} · equivalent time - {:else if item.id === "colorado-teamunify-2026"} + {:else if item.id === "colorado-teamunify-2026" && val.offsetSeconds} · fixed turn offset {:else} · flat multiplier diff --git a/tests/app.spec.ts b/tests/app.spec.ts index 857f1f4..eaf98dd 100644 --- a/tests/app.spec.ts +++ b/tests/app.spec.ts @@ -1071,6 +1071,32 @@ test("benchmark swim selection leads the unified benchmark selector and marks on await expect(estimate).toContainText("1:06.72"); }); +test("Colorado's formula names a turn offset only when it adds one", async ({ + page, +}) => { + const colorado = async (to: string) => { + await page.getByTestId(`result-${to}`).click(); + // A hash change keeps the page, so the details may already be open. + const details = page.locator(".result-method"); + if (!(await details.evaluate((el) => (el as HTMLDetailsElement).open))) + await details.locator("summary").click(); + return page + .getByRole("region", { name: `Compare methods in ${to}` }) + .locator('[data-method="colorado-teamunify-2026"] .method-formula'); + }; + // Distance freestyle multiplies only: ×1.11 for yards to short-course meters. + await page.goto("./#t=17%3A36.20&s=FR&d=1500&c=SCY&g=women&h=0"); + const mile = await colorado("SCM"); + await expect(mile).toContainText("1.11000"); + await expect(mile).toContainText("flat multiplier"); + await expect(mile).not.toContainText("turn offset"); + // The 100 free adds a fixed per-50 offset to long course. + await page.goto("./#t=58.32&s=FR&d=100&c=SCY&g=women&h=0"); + const sprint = await colorado("LCM"); + await expect(sprint).toContainText("+ 1.60s"); + await expect(sprint).toContainText("fixed turn offset"); +}); + test("method spectrum keeps close method labels apart and inside the card", async ({ page, }, testInfo) => { From ff119d00a83903b422f0b6cacb9b35a5f1b36a00 Mon Sep 17 00:00:00 2001 From: Bri Stanback <233396+Stanback@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:28:29 -0700 Subject: [PATCH 2/2] feat(core): report the conversion formula from the engine ConversionValue gains `formula`: none, multiplier, multiplier-offset or equivalent. Clients now describe the arithmetic from the engine instead of inferring it from the method, which is how the web labelled Colorado's multiply-only distance rules as a "fixed turn offset". The web comparison and the MCP card render the label from `formulaText` in the shared copy; the card resource moves to 0.3.0-r9. The conformance fixture asserts the formula for same-course, NCAA, Colorado offset and multiply-only cases, and adds a Turnmark Equivalence conversion case. That case exposed test-mcp comparing methodDifferenceSeconds against the first two methods; it now follows the documented rule (NCAA against Colorado, or against Equivalence when selected). Co-Authored-By: Claude Opus 5.5 --- crates/turnmark-core/src/engine.rs | 20 ++++++++++-- crates/turnmark-core/src/types.rs | 16 +++++++++ docs/MCP_APPS_REVIEW.md | 2 +- fixtures/sdk-conformance.json | 52 ++++++++++++++++++++++++++---- packages/mcp/CHANGELOG.md | 3 ++ packages/mcp/README.md | 2 +- packages/mcp/src/server.ts | 2 +- packages/mcp/ui/app.ts | 4 ++- packages/sdk/CHANGELOG.md | 3 ++ packages/sdk/README.md | 4 ++- packages/sdk/src/conversion.ts | 7 ++++ scripts/ci-changes.test.mjs | 4 +-- scripts/test-mcp-ui.mjs | 9 +++++- scripts/test-mcp.mjs | 11 +++++-- src/lib/MethodComparison.svelte | 9 ++---- src/lib/calculation-copy.ts | 8 +++++ 16 files changed, 130 insertions(+), 26 deletions(-) diff --git a/crates/turnmark-core/src/engine.rs b/crates/turnmark-core/src/engine.rs index f400bdb..40ca47a 100644 --- a/crates/turnmark-core/src/engine.rs +++ b/crates/turnmark-core/src/engine.rs @@ -317,8 +317,14 @@ pub(crate) fn convert_seconds_with_method( return Err(invalid("swim.time", ValidationReason::InvalidTime)); } let mut empirical_caveat = None; - let (seconds, factor, offset_seconds, kind) = if event.course == to { - (seconds, 1.0, 0.0, ConversionKind::Swum) + let (seconds, factor, offset_seconds, kind, formula) = if event.course == to { + ( + seconds, + 1.0, + 0.0, + ConversionKind::Swum, + ConversionFormula::None, + ) } else if method == ConversionMethod::Colorado { let Some((factor, offset)) = colorado_formula(&event, distance, to) else { return Err(unavailable(CoverageReason::FactorNotPublished, provenance)); @@ -327,7 +333,12 @@ pub(crate) fn convert_seconds_with_method( if !converted.is_finite() || converted <= 0.0 { return Err(invalid("swim.time", ValidationReason::UnsafeResult)); } - (converted, factor, offset, ConversionKind::Derived) + let formula = if offset == 0.0 { + ConversionFormula::Multiplier + } else { + ConversionFormula::MultiplierOffset + }; + (converted, factor, offset, ConversionKind::Derived, formula) } else if method == ConversionMethod::Empirical { let Some(equivalent) = empirical_equivalent(&event, distance, to, table, seconds) else { return Err(unavailable(CoverageReason::FactorNotPublished, provenance)); @@ -345,6 +356,7 @@ pub(crate) fn convert_seconds_with_method( equivalent / seconds, 0.0, ConversionKind::Derived, + ConversionFormula::Equivalent, ) } else { let source = factor(&event, distance, table); @@ -362,6 +374,7 @@ pub(crate) fn convert_seconds_with_method( } else { ConversionKind::Derived }, + ConversionFormula::Multiplier, ) }; Ok(( @@ -371,6 +384,7 @@ pub(crate) fn convert_seconds_with_method( hundredths: (seconds * 100.0).round() as u64, factor, offset_seconds, + formula, altitude: None, caveat: if method == ConversionMethod::Colorado && event.stroke == Stroke::Freestyle diff --git a/crates/turnmark-core/src/types.rs b/crates/turnmark-core/src/types.rs index 875ca48..b687225 100644 --- a/crates/turnmark-core/src/types.rs +++ b/crates/turnmark-core/src/types.rs @@ -114,6 +114,21 @@ pub enum ConversionKind { Published, Derived, } +/// How the engine computed the time, so clients describe the arithmetic instead of inferring it +/// from the method. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "native", derive(uniffi::Enum))] +pub enum ConversionFormula { + /// Same course: the entered time, unchanged. + None, + /// Time × `factor`. + Multiplier, + /// Time × `factor` + `offset_seconds`. + MultiplierOffset, + /// The equivalence model's mapping; `factor` is the ratio for this entered time. + Equivalent, +} #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] #[cfg_attr(feature = "native", derive(uniffi::Enum))] @@ -168,6 +183,7 @@ record!(ConversionValue { #[serde(default, skip_serializing_if = "is_zero")] offset_seconds: f64, kind: ConversionKind, + formula: ConversionFormula, event: SwimEvent, #[serde(default, skip_serializing_if = "Option::is_none")] altitude: Option, diff --git a/docs/MCP_APPS_REVIEW.md b/docs/MCP_APPS_REVIEW.md index b5353fb..8f2296a 100644 --- a/docs/MCP_APPS_REVIEW.md +++ b/docs/MCP_APPS_REVIEW.md @@ -51,7 +51,7 @@ text, the clock fits one line and surrounding copy wraps normally: Only `convert_swim` advertises `_meta.ui.resourceUri`; the one static resource is registered independently of initialization capabilities for stateless HTTP compatibility. -- Current source URI `ui://turnmark/conversion/0.3.0-r8.html`, MIME +- Current source URI `ui://turnmark/conversion/0.3.0-r9.html`, MIME `text/html;profile=mcp-app`. Change the resource version for later view releases. The revised resource has a new URI to avoid reusing the previous view's cache key. The alternative-method release is not yet published to npm; published `0.2.0` keeps diff --git a/fixtures/sdk-conformance.json b/fixtures/sdk-conformance.json index af430b1..c0aa9b6 100644 --- a/fixtures/sdk-conformance.json +++ b/fixtures/sdk-conformance.json @@ -273,7 +273,8 @@ "value": { "time": "52.44", "hundredths": 5244, - "kind": "published" + "kind": "published", + "formula": "multiplier" } }, "source": "https://ncaaorg.s3.amazonaws.com/championships/sports/swimdive/rules/PRXSW_RulesBook.pdf#page=125", @@ -332,7 +333,8 @@ "distance": 100, "stroke": "freestyle", "course": "SCY" - } + }, + "formula": "none" } }, "source": "https://ncaaorg.s3.amazonaws.com/championships/sports/swimdive/rules/PRXSW_RulesBook.pdf#page=125", @@ -358,7 +360,8 @@ "value": { "time": "1:09.52", "hundredths": 6952, - "kind": "derived" + "kind": "derived", + "formula": "multiplier" } }, "source": "https://ncaaorg.s3.amazonaws.com/championships/sports/swimdive/rules/PRXSW_RulesBook.pdf#page=125", @@ -1527,7 +1530,8 @@ "hundredths": 6633, "kind": "derived", "factor": 1.11, - "offsetSeconds": 1.6 + "offsetSeconds": 1.6, + "formula": "multiplier-offset" }, "provenance": { "id": "colorado-teamunify-2026" @@ -1622,7 +1626,8 @@ "time": "4:27.75", "hundredths": 26775, "kind": "derived", - "factor": 0.8925 + "factor": 0.8925, + "formula": "multiplier" }, "provenance": { "id": "colorado-teamunify-2026" @@ -1653,7 +1658,8 @@ "time": "5:33.00", "hundredths": 33300, "kind": "derived", - "factor": 1.11 + "factor": 1.11, + "formula": "multiplier" }, "provenance": { "id": "colorado-teamunify-2026" @@ -1685,7 +1691,8 @@ "hundredths": 30640, "kind": "derived", "factor": 1, - "offsetSeconds": 6.4 + "offsetSeconds": 6.4, + "formula": "multiplier-offset" }, "provenance": { "id": "colorado-teamunify-2026" @@ -1806,6 +1813,37 @@ "source": "data/colorado.json; SportsEngine Motion USA Swimming teams formulas, 2026-08-06", "rationale": "Direction-specific documented affine formula; truncate only the final converted result." }, + { + "id": "equivalence-free-100-forward", + "operation": "convert", + "input": { + "swim": { + "time": "58.32", + "event": { + "distance": 100, + "stroke": "freestyle", + "course": "SCY" + } + }, + "to": "LCM", + "table": "women", + "method": "turnmark-empirical-2026" + }, + "expected": { + "status": "available", + "value": { + "time": "1:07.26", + "hundredths": 6726, + "kind": "derived", + "formula": "equivalent" + }, + "provenance": { + "id": "turnmark-empirical-2026" + } + }, + "source": "data/empirical.json 100-FR|women; research/conversion-model/REPORT.md", + "rationale": "The equivalence model reports its formula as an equivalent-time mapping, never a multiplier with an offset." + }, { "id": "ncaa-default-stays-same", "operation": "convert", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 361c1fe..fe9b6c5 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Method rows name the arithmetic the engine reports (`formula`): flat multiplier, + fixed turn offset or equivalent time. UI resource revision is `0.3.0-r9.html`. + - Close methods on the card's method spectrum no longer print their labels over each other: a label that would collide drops to a lower row with a leader tick back to its dot, using the same layout as the web app. UI resource revision is diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e23c0fe..bc1eafa 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -143,7 +143,7 @@ Clients without MCP Apps continue to receive the same text and structured results. There is no separate UI endpoint to configure: use the same stdio server or Streamable HTTP URL. Published package `0.2.0` uses immutable resource `ui://turnmark/conversion/0.2.0.html`. The current source prepares `0.3.0` with SDK `0.2.0`, shared glyphs and -separate Course conversion and altitude controls under collapsed Calculation options at `ui://turnmark/conversion/0.3.0-r8.html`. +separate Course conversion and altitude controls under collapsed Calculation options at `ui://turnmark/conversion/0.3.0-r9.html`. Omitting `method` retains NCAA; `colorado-teamunify-2026` selects documented Colorado Timing formulas. `get_sources` lists both editions. These npm versions are not yet published; SDK 0.2.0 must publish before MCP 0.3.0. Both use `text/html;profile=mcp-app`. Local Apps require package `0.2.0` or later; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3ff2b93..b2b2e68 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -27,7 +27,7 @@ import { pointsValidity, } from "@turnmark/sdk/points"; -export const conversionResourceUri = "ui://turnmark/conversion/0.3.0-r8.html"; +export const conversionResourceUri = "ui://turnmark/conversion/0.3.0-r9.html"; const uiMeta = { ui: { csp: { connectDomains: [], resourceDomains: [], frameDomains: [] }, diff --git a/packages/mcp/ui/app.ts b/packages/mcp/ui/app.ts index a37aee0..43cf1c7 100644 --- a/packages/mcp/ui/app.ts +++ b/packages/mcp/ui/app.ts @@ -1,5 +1,6 @@ import { caveatText, + formulaText, ncaaMethodExplanation, empiricalMethodExplanation, empiricalCalculationNote, @@ -353,7 +354,8 @@ function render(value: unknown, request: ConversionRequest) { const formulaSpan = document.createElement("small"); formulaSpan.className = "method-formula"; const off = item.result.value.offsetSeconds; - formulaSpan.textContent = `× ${item.result.value.factor.toFixed(5)}${off ? ` ${off > 0 ? "+" : "−"} ${Math.abs(off).toFixed(2)}s` : ""}`; + const kind = formulaText(item.result.value.formula); + formulaSpan.textContent = `× ${item.result.value.factor.toFixed(5)}${off ? ` ${off > 0 ? "+" : "−"} ${Math.abs(off).toFixed(2)}s` : ""}${kind ? ` · ${kind}` : ""}`; name.append(formulaSpan); } const time = document.createElement("dd"); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 6fde052..28286b3 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,6 +6,9 @@ and direction-specific formulas. Omitted methods retain NCAA results. - Conversion results expose optional `offsetSeconds`; native bindings expose `convertSwimWithMethod` and a typed method enum. +- Conversion results report `formula` (`none`, `multiplier`, `multiplier-offset` + or `equivalent`), so clients describe the arithmetic from the engine instead of + inferring it from the method. - `assessSwimBenchmark` / `engine.benchmark` compare an original swim against youth or USMS targets, returning met states, the achieved level, next target diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ea0f8dd..51a9bef 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -227,7 +227,9 @@ Omit `method` for the unchanged NCAA default, or pass Results carry the selected provenance. Colorado results add an optional `offsetSeconds` (absent when zero): the untruncated calculation is `inputSeconds × factor + offsetSeconds`. Colorado results are estimates and -unsupported formulas remain unavailable. +unsupported formulas remain unavailable. Every result's `formula` names the +arithmetic: `none`, `multiplier`, `multiplier-offset` or `equivalent`; Colorado's +distance-freestyle rules are `multiplier`. `turnmark-empirical-2026` is Turnmark Equivalence: a model-based estimate of the equivalent time in the other course. Each event and category has one linear map diff --git a/packages/sdk/src/conversion.ts b/packages/sdk/src/conversion.ts index ccb83c2..0f29599 100644 --- a/packages/sdk/src/conversion.ts +++ b/packages/sdk/src/conversion.ts @@ -43,6 +43,13 @@ export interface ConversionValue { /** Add after multiplication, before final truncation. Omitted when zero; Turnmark Equivalence never sets it. */ offsetSeconds?: number; kind: "swum" | "published" | "derived"; + /** + * How the engine computed `time`: `none` (same course), `multiplier` + * (× factor), `multiplier-offset` (× factor + offsetSeconds) or `equivalent` + * (Turnmark Equivalence's mapping). Describe the arithmetic from this field + * rather than from the method. + */ + formula: "none" | "multiplier" | "multiplier-offset" | "equivalent"; event: SwimEvent; altitude?: { value: AltitudeValue; provenance: Provenance }; /** diff --git a/scripts/ci-changes.test.mjs b/scripts/ci-changes.test.mjs index 1b24e8d..ddb0111 100644 --- a/scripts/ci-changes.test.mjs +++ b/scripts/ci-changes.test.mjs @@ -161,7 +161,7 @@ function mcpLiveVerifier({ staleLists = 0, wrongPreview = false } = {}) { "console", `return (async () => {${script}})()`, ); - const uri = "ui://turnmark/conversion/0.3.0-r8.html"; + const uri = "ui://turnmark/conversion/0.3.0-r9.html"; const names = [ "adjust_altitude", "assess_swim_benchmark", @@ -188,7 +188,7 @@ function mcpLiveVerifier({ staleLists = 0, wrongPreview = false } = {}) { inputSchema: { properties: { preview: { type: "object" } } }, _meta: { ui: { - resourceUri: lists <= staleLists ? uri.replace("r8", "r7") : uri, + resourceUri: lists <= staleLists ? uri.replace("r9", "r8") : uri, }, }, })), diff --git a/scripts/test-mcp-ui.mjs b/scripts/test-mcp-ui.mjs index 57641ea..d68df42 100644 --- a/scripts/test-mcp-ui.mjs +++ b/scripts/test-mcp-ui.mjs @@ -54,7 +54,7 @@ if (mode === "HTTP") { }), ); } -const uri = "ui://turnmark/conversion/0.3.0-r8.html"; +const uri = "ui://turnmark/conversion/0.3.0-r9.html"; const card = (await client.readResource({ uri })).contents[0].text; const host = await build({ absWorkingDir: process.cwd(), @@ -139,6 +139,13 @@ try { .textContent(); assert.match(equivalence, /Turnmark Equivalence/); assert.match(equivalence, /1:07.26/); + assert.match(equivalence, /equivalent time/); + assert.match( + await frame + .locator('[data-method="colorado-teamunify-2026"]') + .textContent(), + /\+ 1\.60s · fixed turn offset/, + ); assert.match( await frame.locator("#result").textContent(), /Estimates the equivalent time/, diff --git a/scripts/test-mcp.mjs b/scripts/test-mcp.mjs index 8dc93e7..dd412b8 100644 --- a/scripts/test-mcp.mjs +++ b/scripts/test-mcp.mjs @@ -82,7 +82,7 @@ async function verify(transport, mode, label, ui = false) { listed.map((t) => t.name).sort(), [...Object.values(tools), "get_sources"].sort(), ); - const uri = "ui://turnmark/conversion/0.3.0-r8.html"; + const uri = "ui://turnmark/conversion/0.3.0-r9.html"; assert.equal( listed.find((t) => t.name === "convert_swim")._meta.ui.resourceUri, uri, @@ -178,7 +178,14 @@ async function verify(transport, mode, label, ui = false) { item.result, engine.convert({ ...f.input, method: item.method }), ); - const [a, b] = methodComparison.map((item) => item.result); + // NCAA against Colorado, or against Turnmark Equivalence when selected. + const [a, colorado, equivalence] = methodComparison.map( + (item) => item.result, + ); + const b = + f.input.method === "turnmark-empirical-2026" + ? equivalence + : colorado; assert.equal( methodDifferenceSeconds, a.status === "available" && b.status === "available" diff --git a/src/lib/MethodComparison.svelte b/src/lib/MethodComparison.svelte index af61ac2..963a241 100644 --- a/src/lib/MethodComparison.svelte +++ b/src/lib/MethodComparison.svelte @@ -1,6 +1,7 @@