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 cbefc57..963a241 100644 --- a/src/lib/MethodComparison.svelte +++ b/src/lib/MethodComparison.svelte @@ -1,6 +1,7 @@