Skip to content

Canonicalization & equivalence

Two independent implementations only interoperate if they agree on when two records mean the same thing. OpenBody defines this precisely: equivalence is semantic, not byte-level — two documents are equivalent iff they denote the same set of records under the model’s defined shorthand equivalences (SPEC §8.3), regardless of JSON key order, whitespace, number spelling, or the permitted shorthands. The conformance suite judges this mechanically: reduce each record to a canonical byte string via an ordered, deterministic algorithm, then compare those strings.

This page explains that algorithm plainly. The normative version is the companion document conformance/EQUIVALENCE.md, grounded on RFC 8785 (JSON Canonicalization Scheme). It is normative for the conformance suite — test tooling and the reference implementation implement it, acting as the equivalence oracle. A conformant implementation is not required to implement it: per SPEC §8.3, an implementation is judged by its inputs and outputs against the published vectors. You may implement it (e.g. to self-test); the oracle must follow it exactly.

Round-trip = parse → canonicalize → serialize

A “lossless round-trip” is: parse the record, process it with no change of meaning, serialize. An implementation demonstrates conformance by round-tripping the test vectors; the suite checks each result by canonicalizing both sides with this algorithm and comparing the bytes.

The ordered algorithm

  1. Numbers → exact-decimal fixed-point. Every number is read from its decimal text, never via binary float (so 37.4220 is exactly 37422 × 10⁻³, not a float64 approximation), and replaced by its lowest-terms fixed-point object with string coefficient/exponent. So 72, 72.0, and {coefficient: 720, exponent: -1} all become {"coefficient":"72","exponent":"0"}. Because they’re strings, there’s no 2⁵³ precision ceiling — arbitrary-precision decimals compare exactly. Timestamps are likewise canonicalized to a single spelling (uppercase T/Z, Z for zero offset, trailing-zero fractional seconds removed).
  2. Canonicalize units. A metric unit equal to the field’s default is removed (so time: 120 and time: {absolute:{value:120, unit:"s"}} converge). A unit inside load.value is moved to its canonical home, Load.unit; likewise a unit inside Intensity.value moves to Intensity.unit. This applies to whichever Target variant is present, including ramp — but a ramp’s from/to are never canonicalized by value: they are preserved exactly as authored, with no reordering (order is meaning — a cooldown ramps down).
  3. Expand scalar metrics. A bare scalar n becomes { "absolute": { "value": n } } for every metric field and for load.value.
  4. Expand & fold ExerciseRef. A bare-string ref becomes { "id": … }; an explicit openbody: prefix on a canonical id is folded to the unprefixed form.
  5. Expand roundScheme, then sets. A Block with a laddered roundScheme (e.g. [21,15,9]) is expanded to its enumerated rounds — each round’s descendant WorkUnits whose primary metric is absent take that round’s count — and then a prescription with sets: N is replaced by N sibling WorkUnits. Both are planned shorthands and must not appear with a performance.
  6. Assign deterministic ids (root-down). Any record still lacking an id gets <nearestAncestorId>#<containerField>#<index> (e.g. ex-1#workUnits#3). # is reserved in producer ids, so assigned ids never collide.
  7. Flatten containment. Each inlined child becomes a standalone record with an explicit partOf link to its parent; containment arrays are removed. subject and the nearest ancestor’s startTime/endTime propagate down (an explicit value on the child wins).
  8. Default status. Absent statusactive.
  9. Serialize canonically. Order the six set-valued arrayslinks by (type, ref), effortLoad by (kind, method), intensity by (dimension), modifiers by (type), media by (url), and qualities by token value — with ties broken by canonical bytes; all other arrays keep their semantic order. Then serialize per RFC 8785: lexicographic key sort, canonical escaping, no insignificant whitespace.

The set of canonical record byte strings (one per flattened record) is compared as an unordered set.

Nested ≡ flat + partOf

A direct payoff of flattening (step 7): the §5 hierarchy can be encoded two equivalent ways — a nested document (a child inlined in its parent, the recommended transmission form) or flat + partOf (a child as a standalone record linking to its container). A consumer MUST NOT treat them as different activities, and the conformance vectors assert one structure’s two encodings equivalent.