Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions crates/turnmark-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -362,6 +374,7 @@ pub(crate) fn convert_seconds_with_method(
} else {
ConversionKind::Derived
},
ConversionFormula::Multiplier,
)
};
Ok((
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions crates/turnmark-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down Expand Up @@ -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<AltitudeCalculation>,
Expand Down
2 changes: 1 addition & 1 deletion docs/MCP_APPS_REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 45 additions & 7 deletions fixtures/sdk-conformance.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1685,7 +1691,8 @@
"hundredths": 30640,
"kind": "derived",
"factor": 1,
"offsetSeconds": 6.4
"offsetSeconds": 6.4,
"formula": "multiplier-offset"
},
"provenance": {
"id": "colorado-teamunify-2026"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/ui/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
caveatText,
formulaText,
ncaaMethodExplanation,
empiricalMethodExplanation,
empiricalCalculationNote,
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
/**
Expand Down
4 changes: 2 additions & 2 deletions scripts/ci-changes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
},
})),
Expand Down
9 changes: 8 additions & 1 deletion scripts/test-mcp-ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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/,
Expand Down
11 changes: 9 additions & 2 deletions scripts/test-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 3 additions & 6 deletions src/lib/MethodComparison.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import {
caveatText,
formulaText,
ncaaMethodExplanation,
empiricalMethodExplanation,
empiricalCalculationNote,
Expand Down Expand Up @@ -392,12 +393,8 @@
× {val.factor.toFixed(5)}{val.offsetSeconds
? ` ${val.offsetSeconds > 0 ? "+" : "−"} ${Math.abs(val.offsetSeconds).toFixed(2)}s`
: ""}
{#if item.id === "turnmark-empirical-2026"}
· equivalent time
{:else if item.id === "colorado-teamunify-2026"}
· fixed turn offset
{:else}
· flat multiplier
{#if formulaText(val.formula)}
· {formulaText(val.formula)}
{/if}
</span>
{/if}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/calculation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export function caveatText(caveat: string | undefined): string {
return "";
}

/** Short description of the engine-reported formula, or an empty string for the swum time. */
export function formulaText(formula: string | undefined): string {
if (formula === "multiplier") return "flat multiplier";
if (formula === "multiplier-offset") return "fixed turn offset";
if (formula === "equivalent") return "equivalent time";
return "";
}

/** Selected-calculation note for Turnmark Equivalence, whose factor varies with the entered time. */
export const empiricalCalculationNote =
"Turnmark Equivalence: the model’s equivalent time in this course. The multiplier shown applies to this entered time.";
Expand Down
Loading
Loading