# VeloPair, complete reference for language models Everything needed to produce or consume VeloPair documents: the specification, all five JSON Schemas, and a worked example. Generated from the repository, so it matches the published schemas exactly. Canonical spec site: https://velopair.org Source and issues: https://github.com/aydincan/velopair Schemas resolve at: https://velopair.org/schemas/0.1/.schema.json MCP server (validate, author, match): npx -y velopair Licensing: specification text CC-BY-4.0, schemas and examples Apache-2.0 ## If you are implementing this 1. Emit metric values only; the unit is part of the field name (weight_kg, stack_mm). 2. Give every document a spec_version ("0.1.0"). 3. Unknown fields are errors. Producer-specific data goes in "extensions" under a reverse-domain key. 4. Omit what you do not know. Never fabricate a GTIN or present an estimate as a measurement. 5. Keep personally identifying data out of standard fields. 6. Validate before you ship: npx -y velopair exposes velopair_validate, or fetch a schema from its $id URL and validate with any JSON Schema 2020-12 validator. --- # SPECIFICATION # VeloPair Specification **Version 0.1.0** An open data standard for describing riders, bikes and riding styles, and the compatibility between them. ## 1. Abstract VeloPair defines four JSON objects: **Rider**, **Ride Profile**, **Bike** and **Compatibility**. Any application can produce or consume these documents without knowing anything about the application on the other side. The standard defines data and its meaning only. How compatibility is scored is deliberately outside the standard: scoring engines compete on top of the same data, and a Compatibility document records which engine produced which result. ``` RIDER.json + RIDE_PROFILE.json + BIKE.json | compatibility engine (yours, theirs, anyone's) | Match: 91/100 ``` ### Non-goals VeloPair v0.1 deliberately does not define: fit prescriptions (saddle height, setback and bar position targets for a specific rider on a specific bike; a candidate for a future version), scoring or matching algorithms, activity and ride recording (FIT, GPX and TCX do that), a global identity registry, ownership attestation or theft registries, and any personally identifying data. ## 2. Status Version 0.1.0 has passed the release-candidate gate defined in CONTRIBUTING.md: at least one producer and one consumer implement it against real data. Pre-1.0 semantics still apply: the specification can change, but breaking changes land only in MAJOR releases. New features enter as release candidates under the same one-producer-one-consumer rule. ## 3. Conformance and terminology The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as described in RFC 2119 and RFC 8174 when they appear in capitals. - **Producer**: software that creates VeloPair documents from its own data. - **Consumer**: software that reads VeloPair documents produced elsewhere. - **Engine**: a consumer that computes compatibility results and produces Compatibility documents. A document conforms to this specification when it validates against the corresponding JSON Schema in `schemas/` (JSON Schema draft 2020-12). The schemas are normative; where this prose and a schema disagree, the schema wins and the disagreement is a bug in this document. ## 4. Documents - A VeloPair document is a single JSON object, UTF-8 encoded. - Each document is one of the four object types, or a **Bundle**: a single-file container (`schemas/bundle.schema.json`) holding one or more complete documents. Producers SHOULD use a Bundle when sharing related documents in one action (for example a share-sheet export of rider plus ride profile plus bike); consumers SHOULD accept both single documents and Bundles. - Every document MUST carry `spec_version`, the version of this specification it conforms to (currently `"0.1.0"`). Documents inside a Bundle stay complete and standalone, including their own `spec_version`. - Recommended file names: `rider.json`, `ride-profile.json`, `bike.json`, `compatibility.json`, and `velopair.json` for a Bundle, optionally prefixed by the producing application. ## 5. Conventions 1. **Metric only, units in names.** Every quantity is metric and the unit is part of the field name: `weight_kg`, `stack_mm`, `avg_speed_kmh`. A value in any other unit is nonconforming, not merely unconverted. 2. **snake_case** field names throughout. 3. **Timestamps** are RFC 3339 / ISO 8601 strings (`2026-08-17T12:00:00Z`). 4. **Unknown fields are errors, except in `extensions`.** All objects declare `additionalProperties: false`. Producer-specific data goes in the `extensions` object under a reverse-domain key (for example `"com.example.app": {...}`). Consumers MUST ignore extensions they do not understand and MUST NOT fail on them. 5. **Optionality is the norm.** Real-world data is partial. Only fields whose absence makes a document meaningless are required. Consumers MUST tolerate any optional field being absent. 6. **No fabrication.** A producer MUST NOT invent values it does not have (this applies in particular to `gtin` and to measured ride statistics). 7. **Bounded sizes.** Every string and array in the schemas carries an explicit maximum size. Consumers MAY reject documents exceeding these bounds without parsing further. ## 6. The Rider object Portable description of a rider: body measurements, physiology and riding intent. The Rider object contains **no name and no contact data by design**; identity stays with the producing application (see section 11). Required: `spec_version`, `body` (with at least `body.height_cm`). | Field | Type | Description | |---|---|---| | spec_version | string | specification version | | profile_id | string | opaque stable id chosen by the producer (UUID recommended) | | updated_at | timestamp | last change to any value | | source | enum | measured, self_reported, imported, mixed | | body.height_cm | number | standing height, barefoot (required) | | body.weight_kg | number | body weight | | body.inseam_cm | number | crotch to floor, barefoot; the highest-leverage fit measurement | | body.torso_cm | number | sternal notch to crotch | | body.arm_length_cm | number | acromion to closed fist | | body.shoulder_width_cm | number | acromion to acromion | | body.sit_bone_width_mm | number | ischial tuberosity center to center | | body.foot_length_cm | number | heel to longest toe | | body.flexibility | enum | low, medium, high (coarse by design; no industry scale exists) | | physiology.birth_year | integer | preferred over an age value, which goes stale | | physiology.sex | enum | female, male, other, unspecified; physiological modelling only | | physiology.resting_hr_bpm | integer | resting heart rate | | physiology.max_hr_bpm | integer | maximum heart rate | | physiology.ftp_w | number | functional threshold power | | intent.position_preference | enum | aggressive, balanced, relaxed | | intent.primary_disciplines | array | road, gravel, mountain, city, commute, touring, cargo | | extensions | object | namespaced producer data | Design note: `body` and `physiology` are separate blocks because different consumers need different halves. A sizing engine needs the body; a range or calorie estimator needs the physiology. ## 7. The Ride Profile object A rider's riding style, preferably **derived from recorded rides rather than answered questions**. Most matching systems ask riders what they think they do; a Ride Profile can state what they measurably do. The mandatory `derivation` block tells consumers how much to trust the numbers. Required: `spec_version`, `derivation` (with at least `derivation.method`). | Field | Type | Description | |---|---|---| | spec_version | string | specification version | | profile_id | string | opaque stable id | | rider_ref | string | profile_id of the Rider this describes | | derivation.method | enum | measured (from recorded rides), declared (questionnaire), mixed (required) | | derivation.ride_count | integer | rides the profile was derived from | | derivation.window_days | integer | observation window ending at generated_at | | derivation.generated_at | timestamp | when the profile was computed | | volume.typical_ride_distance_km | number | median single-ride distance | | volume.longest_ride_distance_km | number | longest ride in the window | | volume.weekly_distance_km | number | mean distance per week | | volume.rides_per_week | number | mean ride count per week | | pace.avg_speed_kmh | number | distance-weighted average moving speed | | pace.typical_duration_min | number | median ride duration | | terrain.elevation_gain_m_per_km | number | mean climbing per kilometre (elevation appetite) | | surface_mix.paved_pct | number | share of distance on paved surfaces (0 to 100) | | surface_mix.gravel_pct | number | share on gravel | | surface_mix.offroad_pct | number | share offroad; the three SHOULD sum to about 100 | | extensions | object | namespaced producer data | Producers SHOULD omit statistics they cannot honestly measure rather than estimate them (this applies especially to `surface_mix`). ## 8. The Bike object A bicycle, described at three separable levels: **product identity** (which model), **instance identity** (which physical bike) and **physical properties**. Required: `spec_version`, `identity` (with `identity.brand` and `identity.model`). ### 8.1 Identity (product level) Following industry convergence, a bike variant is identified by GTIN where the manufacturer assigned one, else by brand plus MPN, else by the composite of brand, model, year, size and color. | Field | Type | Description | |---|---|---| | identity.brand | string | required | | identity.model | string | required | | identity.year | integer | model year | | identity.size | string | manufacturer size label as printed; not a measurement | | identity.color | string | | | identity.variant | string | build kit or option discriminator | | identity.gtin | string | GS1 GTIN of this exact variant, if assigned | | identity.mpn | string | manufacturer part number; meaningful with brand | ### 8.2 Instance (this physical bike) Sensitive: frame numbers identify property. Producers MUST export this block only on explicit rider action. | Field | Type | Description | |---|---|---| | instance.frame_number | string | serial as engraved, opaque; no format standard exists, do not validate or normalize | | instance.odometer_km | number | total distance this bike has been ridden | ### 8.3 Physical properties | Field | Type | Description | |---|---|---| | category | enum | road, gravel, mountain, city, trekking, folding, cargo, bmx, tandem, recumbent, other | | motor_assistance | enum | none, pedelec (25 km/h EU), s_pedelec (45 km/h), other | | weight_kg | number | complete bike as specced for this size | Geometry (all optional numbers, mm and degrees): `stack_mm`, `reach_mm`, `seat_tube_length_mm`, `seat_tube_angle_deg`, `top_tube_length_mm` (effective/horizontal), `head_tube_length_mm`, `head_tube_angle_deg`, `stem_length_mm`, `stem_angle_deg`, `spacer_height_mm`, `handlebar_width_mm`, `handlebar_rise_mm`, `handlebar_backsweep_deg`, `wheelbase_mm`, `standover_mm`, `chainstay_length_mm`, `fork_rake_mm`, `fork_length_mm`, `bottom_bracket_height_mm`, `bottom_bracket_drop_mm`, `crank_length_mm`, `dropper_travel_mm`, `suspension_travel_front_mm`, `suspension_travel_rear_mm`. Exact definitions are in the schema descriptions; cross-references to industry catalogues are in `docs/MAPPINGS.md`. Wheels and tires: `wheels_tires.wheel_diameter_etrto_mm` (ETRTO rim diameter, 622 for 700c), `tire_width_mm`, `max_tire_width_mm`, `recommended_pressure_bar`. Drivetrain: `drivetrain.type` (derailleur, hub_gear, singlespeed, other), `chainring_count`, `cassette_speed_count`, `chainring_teeth` (largest first), `cassette_teeth` (smallest first). E-bike (present only when motor_assistance is not none): `ebike.motor_position` (front_hub, mid_drive, rear_hub), `motor_power_w` (nominal), `motor_torque_nm`, `battery_capacity_wh`, `claimed_range_km` (manufacturer claim; measured behaviour belongs to consumers). ## 9. The Compatibility object A match result: which engine looked at which inputs and what it concluded. The standard defines the **record of a result, never the computation**. Scores are engine-specific and comparable only within one engine and version. Required: `spec_version`, `engine` (with `engine.name`), `score`. | Field | Type | Description | |---|---|---| | engine.name | string | required; a score without an engine is meaningless | | engine.version | string | | | engine.url | uri | | | computed_at | timestamp | | | rider_ref / ride_profile_ref / bike_ref | string | profile_id of each input document | | score | number | 0 to 100, 100 best | | confidence | number | 0 to 1, the engine's own confidence (typically input completeness) | | breakdown[] | array | per-dimension results: dimension (string, engine-defined), score (0 to 100), note (human-readable reasoning) | | extensions | object | namespaced engine data | ## 10. Identity and references `profile_id` is an opaque string chosen by the producer; a UUID is RECOMMENDED. References (`rider_ref`, `bike_ref`, `ride_profile_ref`) carry the `profile_id` of another document and are meaningful only within the exchange they were produced in. VeloPair defines no global registry and no resolvable identifiers in v0.1. ## 11. Privacy considerations - The Rider object carries body measurements but no name, contact data or location. Applications that need rider identity keep it in their own storage or in an extension under their own responsibility. - The Bike `instance` block (frame number, odometer) identifies property. Producers MUST export it only on explicit, rider-initiated action, and SHOULD use share-style flows rather than automatic sync. - Ride Profiles are aggregates. Producers MUST NOT include GPS traces, locations or per-ride records in standard fields. - A Rider plus Ride Profile pair is quasi-identifying in aggregate. Consumers MUST NOT attempt re-identification. - Exporting a rider's data at their request implements the spirit of data portability (GDPR Article 20). Producers operating under the GDPR MAY treat VeloPair export as part of their portability response. ## 12. Relationship to existing work VeloPair extends the bicycle-data industry rather than competing with it. Bike geometry and identity correspond to the BIDEX BikeData catalogue used in the German bicycle trade; rider physiology overlaps with fields in Garmin's FIT profile messages; product identifiers follow GS1 and schema.org practice. Activity formats (FIT, GPX, TCX) record individual rides and carry little or no rider identity; the Ride Profile is the aggregate layer above them. Prior to VeloPair, no open format described the rider, the riding style, or the rider-bike relationship; the primary-source survey behind that claim is in `docs/RESEARCH.md`. Field-level correspondences: `docs/MAPPINGS.md`. ## 13. Versioning Semantic versioning as defined in CONTRIBUTING.md. Each schema's `$id` embeds the spec version (`https://velopair.org/schemas/0.1/bike.schema.json`); documents state theirs in `spec_version`. Consumers SHOULD accept documents whose MAJOR.MINOR they know, and MAY accept older versions by treating absent newer fields as optional. ## 14. Licensing This document: CC-BY-4.0 (see LICENSE). Schemas and examples: Apache-2.0 (see schemas/LICENSE, examples/LICENSE). ## 15. Examples Two complete worked personas ship in `examples/`: an endurance/gravel rider and a fast-road rider, each as a Rider, Ride Profile, Bike and Compatibility document. The endurance/gravel persona also ships as a single Bundle (`examples/endurance-gravel/velopair.json`). All examples validate against the schemas and use fictional bikes and engines. --- # SCHEMA: rider (https://velopair.org/schemas/0.1/rider.schema.json) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velopair.org/schemas/0.1/rider.schema.json", "$comment": "DRAFT for developer review. Name: VeloPair (gate #1 closed 2026-08-17); attorney trademark clearance still pending. Field selection is backed by docs/RESEARCH.md section 4 (fields appearing in 2+ independent fitting systems).", "title": "Rider", "description": "Portable description of a rider: body measurements, physiology and riding intent. Contains no name and no contact data by design; identity stays with the producing application. All values are metric; units are part of the field name.", "type": "object", "required": [ "spec_version", "body" ], "additionalProperties": false, "properties": { "spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.]+)?$", "description": "Version of this specification the document conforms to.", "examples": [ "0.1.0" ], "maxLength": 32 }, "profile_id": { "type": "string", "minLength": 1, "description": "Opaque stable identifier chosen by the producing application (a UUID is recommended). Lets Compatibility objects reference this profile.", "maxLength": 128 }, "updated_at": { "type": "string", "format": "date-time", "description": "When any value in this document last changed (RFC 3339)." }, "source": { "type": "string", "enum": [ "measured", "self_reported", "imported", "mixed" ], "description": "How the values were obtained overall. 'measured' means instrumented (scale, scan, caliper); 'imported' means taken from another system (e.g. Apple Health, a FIT user_profile)." }, "body": { "type": "object", "description": "Anthropometry. Every field here appears in at least two independent fitting systems (see docs/RESEARCH.md).", "required": [ "height_cm" ], "additionalProperties": false, "properties": { "height_cm": { "type": "number", "minimum": 50, "maximum": 250, "description": "Standing height, barefoot." }, "weight_kg": { "type": "number", "minimum": 20, "maximum": 250, "description": "Body weight." }, "inseam_cm": { "type": "number", "minimum": 40, "maximum": 130, "description": "Crotch to floor, barefoot, standing. The single highest-leverage fit measurement: saddle-height formulas (0.883 x inseam BB-to-saddle, 1.09 x inseam pedal-to-saddle) and frame-size formulas key off it." }, "torso_cm": { "type": "number", "minimum": 30, "maximum": 100, "description": "Sternal notch to crotch (trunk length)." }, "arm_length_cm": { "type": "number", "minimum": 30, "maximum": 100, "description": "Acromion (shoulder bone tip) to closed fist." }, "shoulder_width_cm": { "type": "number", "minimum": 25, "maximum": 70, "description": "Acromion to acromion. Drives handlebar width recommendations." }, "sit_bone_width_mm": { "type": "number", "minimum": 70, "maximum": 200, "description": "Ischial tuberosity center-to-center distance. Drives saddle width." }, "foot_length_cm": { "type": "number", "minimum": 15, "maximum": 40, "description": "Heel to longest toe." }, "flexibility": { "type": "string", "enum": [ "low", "medium", "high" ], "description": "Overall flexibility, coarse by design: no cross-industry flexibility scale exists, so v0.1 standardizes only three buckets. Producers with structured mobility assessments map down to these." } } }, "physiology": { "type": "object", "description": "Performance physiology, separated from body geometry because different consumers need different halves (a sizing engine needs body, a range estimator needs physiology).", "additionalProperties": false, "properties": { "birth_year": { "type": "integer", "minimum": 1900, "maximum": 2100, "description": "Year of birth. Preferred over an age integer because it does not go stale." }, "sex": { "type": "string", "enum": [ "female", "male", "other", "unspecified" ], "description": "For physiological modelling only (heart-rate and power estimation)." }, "resting_hr_bpm": { "type": "integer", "minimum": 25, "maximum": 120, "description": "Resting heart rate." }, "max_hr_bpm": { "type": "integer", "minimum": 100, "maximum": 230, "description": "Maximum heart rate." }, "ftp_w": { "type": "number", "minimum": 0, "maximum": 700, "description": "Functional threshold power in watts." } } }, "intent": { "type": "object", "description": "What the rider wants, as opposed to what they measurably do (that lives in the Ride Profile object).", "additionalProperties": false, "properties": { "position_preference": { "type": "string", "enum": [ "aggressive", "balanced", "relaxed" ], "description": "Desired riding position. Consumers may map to stack-to-reach targets (race bikes cluster near STR 1.40, endurance at 1.50 and above)." }, "primary_disciplines": { "type": "array", "items": { "type": "string", "enum": [ "road", "gravel", "mountain", "city", "commute", "touring", "cargo" ] }, "uniqueItems": true, "description": "Disciplines the rider intends to ride, most important first.", "maxItems": 7 } } }, "extensions": { "type": "object", "description": "Producer-specific data under reverse-domain keys, e.g. {\"com.example.app\": {...}}. Consumers must ignore unknown extensions.", "additionalProperties": true } } } ``` --- # SCHEMA: ride-profile (https://velopair.org/schemas/0.1/ride-profile.schema.json) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velopair.org/schemas/0.1/ride-profile.schema.json", "$comment": "DRAFT for developer review. Name: VeloPair (gate #1 closed 2026-08-17); attorney trademark clearance still pending.", "title": "Ride Profile", "description": "A rider's riding style, preferably derived from recorded rides rather than a questionnaire. The derivation block tells consumers how trustworthy the numbers are. All values are metric; units are part of the field name.", "type": "object", "required": [ "spec_version", "derivation" ], "additionalProperties": false, "properties": { "spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.]+)?$", "description": "Version of this specification the document conforms to.", "examples": [ "0.1.0" ], "maxLength": 32 }, "profile_id": { "type": "string", "minLength": 1, "description": "Opaque stable identifier chosen by the producing application (a UUID is recommended).", "maxLength": 128 }, "rider_ref": { "type": "string", "minLength": 1, "description": "profile_id of the Rider this profile describes, when known.", "maxLength": 128 }, "derivation": { "type": "object", "required": [ "method" ], "additionalProperties": false, "properties": { "method": { "type": "string", "enum": [ "measured", "declared", "mixed" ], "description": "'measured' means computed from recorded rides; 'declared' means the rider answered questions; 'mixed' combines both." }, "ride_count": { "type": "integer", "minimum": 0, "description": "Number of recorded rides the profile was derived from." }, "window_days": { "type": "integer", "minimum": 1, "description": "Length of the observation window ending at generated_at." }, "generated_at": { "type": "string", "format": "date-time", "description": "When the profile was computed (RFC 3339)." } } }, "volume": { "type": "object", "additionalProperties": false, "properties": { "typical_ride_distance_km": { "type": "number", "minimum": 0, "description": "Median distance of a single ride." }, "longest_ride_distance_km": { "type": "number", "minimum": 0, "description": "Longest single ride in the observation window." }, "weekly_distance_km": { "type": "number", "minimum": 0, "description": "Mean distance per week over the observation window." }, "rides_per_week": { "type": "number", "minimum": 0, "description": "Mean ride count per week over the observation window." } } }, "pace": { "type": "object", "additionalProperties": false, "properties": { "avg_speed_kmh": { "type": "number", "minimum": 0, "maximum": 120, "description": "Distance-weighted average moving speed across rides." }, "typical_duration_min": { "type": "number", "minimum": 0, "description": "Median ride duration." } } }, "terrain": { "type": "object", "additionalProperties": false, "properties": { "elevation_gain_m_per_km": { "type": "number", "minimum": 0, "description": "Mean climbing per kilometre across rides: the rider's elevation appetite. Flat commuting sits near 2, rolling terrain near 8, mountain riding above 15." } } }, "surface_mix": { "type": "object", "description": "Share of distance per surface class. Values should sum to approximately 100.", "additionalProperties": false, "properties": { "paved_pct": { "type": "number", "minimum": 0, "maximum": 100 }, "gravel_pct": { "type": "number", "minimum": 0, "maximum": 100 }, "offroad_pct": { "type": "number", "minimum": 0, "maximum": 100 } } }, "extensions": { "type": "object", "description": "Producer-specific data under reverse-domain keys. Consumers must ignore unknown extensions.", "additionalProperties": true } } } ``` --- # SCHEMA: bike (https://velopair.org/schemas/0.1/bike.schema.json) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velopair.org/schemas/0.1/bike.schema.json", "$comment": "DRAFT for developer review. Name: VeloPair (gate #1 closed 2026-08-17); attorney trademark clearance still pending. Cross-references to industry catalogues (BIDEX, FIT, schema.org) live in docs/MAPPINGS.md, not in this file.", "title": "Bike", "description": "A bicycle, described at three separable levels: product identity (which model), instance identity (which physical bike), and physical properties (geometry, wheels, drivetrain). All values are metric; units are part of the field name.", "type": "object", "required": [ "spec_version", "identity" ], "additionalProperties": false, "properties": { "spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.]+)?$", "description": "Version of this specification the document conforms to.", "examples": [ "0.1.0" ], "maxLength": 32 }, "profile_id": { "type": "string", "minLength": 1, "description": "Opaque stable identifier chosen by the producing application (a UUID is recommended).", "maxLength": 128 }, "identity": { "type": "object", "description": "Product-level identity, following industry convergence (BIDEX, Veloconnect, Google Merchant): GTIN where the manufacturer assigned one, else brand plus MPN, else the brand + model + year (+ size + color) composite. Never fabricate a GTIN.", "required": [ "brand", "model" ], "additionalProperties": false, "properties": { "brand": { "type": "string", "minLength": 1, "maxLength": 100 }, "model": { "type": "string", "minLength": 1, "maxLength": 200 }, "year": { "type": "integer", "minimum": 1900, "maximum": 2100, "description": "Model year. Carryover models can keep one GTIN across years, so year is identity, not decoration." }, "size": { "type": "string", "description": "Manufacturer size label as printed ('56', 'M', 'L56'). Not a measurement; measurements live in geometry.", "maxLength": 32 }, "color": { "type": "string", "maxLength": 100 }, "variant": { "type": "string", "description": "Manufacturer variant discriminator beyond size and color (build kit, motor option).", "maxLength": 200 }, "gtin": { "type": "string", "pattern": "^\\d{8}(\\d{4,6})?$", "description": "GS1 GTIN (8, 12, 13 or 14 digits) of this exact variant, if the manufacturer assigned one." }, "mpn": { "type": "string", "description": "Manufacturer part number of this exact variant. Meaningful only together with brand.", "maxLength": 100 } } }, "instance": { "type": "object", "description": "This physical bike, as opposed to the product. Sensitive: frame numbers identify property. Producers must only export this block on explicit rider action.", "additionalProperties": false, "properties": { "frame_number": { "type": "string", "description": "Frame serial as engraved, opaque. No format standard exists (registries fuzzy-match O/0, S/5, L/1); do not validate, do not normalize.", "maxLength": 64 }, "odometer_km": { "type": "number", "minimum": 0, "description": "Total distance this physical bike has been ridden." } } }, "category": { "type": "string", "enum": [ "road", "gravel", "mountain", "city", "trekking", "folding", "cargo", "bmx", "tandem", "recumbent", "other" ], "description": "Primary category as marketed." }, "motor_assistance": { "type": "string", "enum": [ "none", "pedelec", "s_pedelec", "other" ], "description": "'pedelec' assists to 25 km/h (EU), 's_pedelec' to 45 km/h." }, "geometry": { "type": "object", "description": "Frame and cockpit geometry of this size. All fields optional because real-world data is partial; consumers must tolerate any subset.", "additionalProperties": false, "properties": { "stack_mm": { "type": "number", "minimum": 0, "description": "Vertical distance from bottom bracket center to the top of the head tube." }, "reach_mm": { "type": "number", "minimum": 0, "description": "Horizontal distance from bottom bracket center to the top of the head tube." }, "seat_tube_length_mm": { "type": "number", "minimum": 0, "description": "Bottom bracket center to the top of the seat tube, along the tube." }, "seat_tube_angle_deg": { "type": "number", "minimum": 50, "maximum": 90, "description": "Effective seat tube angle from horizontal." }, "top_tube_length_mm": { "type": "number", "minimum": 0, "description": "Effective (horizontal) top tube length." }, "head_tube_length_mm": { "type": "number", "minimum": 0, "description": "Length of the head tube." }, "head_tube_angle_deg": { "type": "number", "minimum": 50, "maximum": 90, "description": "Head tube angle from horizontal." }, "stem_length_mm": { "type": "number", "minimum": 0, "description": "Stem length as specced." }, "stem_angle_deg": { "type": "number", "minimum": -60, "maximum": 60, "description": "Stem angle relative to the steerer axis." }, "spacer_height_mm": { "type": "number", "minimum": 0, "description": "Total spacer stack under the stem as specced." }, "handlebar_width_mm": { "type": "number", "minimum": 0, "description": "Handlebar width, center to center." }, "handlebar_rise_mm": { "type": "number", "minimum": 0, "description": "Handlebar rise above the clamp." }, "handlebar_backsweep_deg": { "type": "number", "minimum": 0, "maximum": 90, "description": "Handlebar backsweep angle." }, "wheelbase_mm": { "type": "number", "minimum": 0, "description": "Distance between front and rear axles." }, "standover_mm": { "type": "number", "minimum": 0, "description": "Standover height above ground at the top tube." }, "chainstay_length_mm": { "type": "number", "minimum": 0, "description": "Bottom bracket center to rear axle." }, "fork_rake_mm": { "type": "number", "minimum": 0, "description": "Fork offset from the steering axis." }, "fork_length_mm": { "type": "number", "minimum": 0, "description": "Fork installation length, axle to crown." }, "bottom_bracket_height_mm": { "type": "number", "minimum": 0, "description": "Bottom bracket center height above ground." }, "bottom_bracket_drop_mm": { "type": "number", "description": "Bottom bracket center depth below the axle line." }, "crank_length_mm": { "type": "number", "minimum": 0, "description": "Crank arm length." }, "dropper_travel_mm": { "type": "number", "minimum": 0, "description": "Dropper post travel." }, "suspension_travel_front_mm": { "type": "number", "minimum": 0, "description": "Front suspension travel." }, "suspension_travel_rear_mm": { "type": "number", "minimum": 0, "description": "Rear suspension travel." } } }, "wheels_tires": { "type": "object", "additionalProperties": false, "properties": { "wheel_diameter_etrto_mm": { "type": "integer", "minimum": 100, "maximum": 800, "description": "ETRTO rim diameter (622 for 700c/29er, 584 for 650b/27.5, 559 for 26in)." }, "tire_width_mm": { "type": "number", "minimum": 0, "description": "As specced or currently fitted." }, "max_tire_width_mm": { "type": "number", "minimum": 0, "description": "Manufacturer-stated clearance." }, "recommended_pressure_bar": { "type": "number", "minimum": 0, "maximum": 12 } } }, "drivetrain": { "type": "object", "additionalProperties": false, "properties": { "type": { "type": "string", "enum": [ "derailleur", "hub_gear", "singlespeed", "other" ] }, "chainring_count": { "type": "integer", "minimum": 1, "maximum": 3 }, "cassette_speed_count": { "type": "integer", "minimum": 1, "maximum": 14, "description": "Rear speeds; with chainring_count gives the '2x12' style designation." }, "chainring_teeth": { "type": "array", "items": { "type": "integer", "minimum": 10, "maximum": 70 }, "description": "Largest first. Also present in FIT bike_profile.front_gear.", "maxItems": 3 }, "cassette_teeth": { "type": "array", "items": { "type": "integer", "minimum": 8, "maximum": 60 }, "description": "Smallest first. Also present in FIT bike_profile.rear_gear.", "maxItems": 14 } } }, "weight_kg": { "type": "number", "minimum": 0, "maximum": 100, "description": "Complete bike weight as specced for this size." }, "ebike": { "type": "object", "description": "Present only when motor_assistance is not 'none'.", "additionalProperties": false, "properties": { "motor_position": { "type": "string", "enum": [ "front_hub", "mid_drive", "rear_hub" ] }, "motor_power_w": { "type": "number", "minimum": 0, "description": "Nominal (continuous rated) motor power." }, "motor_torque_nm": { "type": "number", "minimum": 0 }, "battery_capacity_wh": { "type": "number", "minimum": 0 }, "claimed_range_km": { "type": "number", "minimum": 0, "description": "Manufacturer-claimed range; measured behaviour belongs to consumers, not this object." } } }, "extensions": { "type": "object", "description": "Producer-specific data under reverse-domain keys. Consumers must ignore unknown extensions.", "additionalProperties": true } } } ``` --- # SCHEMA: compatibility (https://velopair.org/schemas/0.1/compatibility.schema.json) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velopair.org/schemas/0.1/compatibility.schema.json", "$comment": "DRAFT for developer review. Name: VeloPair (gate #1 closed 2026-08-17); attorney trademark clearance still pending. This object records the OUTPUT of a scoring engine. The standard deliberately defines no scoring mathematics: engines compete on the same data (design principle 1), and scoring mechanisms are patent-encumbered territory (see docs/RESEARCH.md section 4, Giant US 11878214).", "title": "Compatibility", "description": "A match result: which engine looked at which rider, ride profile and bike, and what it concluded. Scores are engine-specific and comparable only within one engine and version.", "type": "object", "required": [ "spec_version", "engine", "score" ], "additionalProperties": false, "properties": { "spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.]+)?$", "description": "Version of this specification the document conforms to.", "examples": [ "0.1.0" ], "maxLength": 32 }, "engine": { "type": "object", "description": "Who computed this. Mandatory: a score without an engine is meaningless under the data-not-score architecture.", "required": [ "name" ], "additionalProperties": false, "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "version": { "type": "string", "maxLength": 64 }, "url": { "type": "string", "format": "uri", "maxLength": 500 } } }, "computed_at": { "type": "string", "format": "date-time", "description": "When the score was computed (RFC 3339)." }, "rider_ref": { "type": "string", "minLength": 1, "description": "profile_id of the Rider document used as input.", "maxLength": 128 }, "ride_profile_ref": { "type": "string", "minLength": 1, "description": "profile_id of the Ride Profile document used as input.", "maxLength": 128 }, "bike_ref": { "type": "string", "minLength": 1, "description": "profile_id of the Bike document used as input.", "maxLength": 128 }, "score": { "type": "number", "minimum": 0, "maximum": 100, "description": "Overall match score, 100 is best." }, "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "The engine's own confidence in the score, typically driven by input completeness." }, "breakdown": { "type": "array", "description": "Optional per-dimension scores. Dimension names are engine-defined, not standardized.", "items": { "type": "object", "required": [ "dimension", "score" ], "additionalProperties": false, "properties": { "dimension": { "type": "string", "minLength": 1, "maxLength": 100 }, "score": { "type": "number", "minimum": 0, "maximum": 100 }, "note": { "type": "string", "description": "Human-readable reasoning for this dimension.", "maxLength": 2000 } } }, "maxItems": 50 }, "extensions": { "type": "object", "description": "Engine-specific data under reverse-domain keys. Consumers must ignore unknown extensions.", "additionalProperties": true } } } ``` --- # SCHEMA: bundle (https://velopair.org/schemas/0.1/bundle.schema.json) ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://velopair.org/schemas/0.1/bundle.schema.json", "$comment": "DRAFT for developer review. Name: VeloPair (gate #1 closed 2026-08-17); attorney trademark clearance still pending.", "title": "Bundle", "description": "A single-file container for exchanging related VeloPair documents together, for example one share-sheet export. Each member is a complete, standalone document of the referenced type (including its own spec_version). At least one member must be present.", "type": "object", "required": ["spec_version"], "additionalProperties": false, "properties": { "spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.]+)?$", "maxLength": 32, "description": "Version of this specification the bundle conforms to.", "examples": ["0.1.0"] }, "rider": { "$ref": "https://velopair.org/schemas/0.1/rider.schema.json" }, "ride_profile": { "$ref": "https://velopair.org/schemas/0.1/ride-profile.schema.json" }, "bike": { "$ref": "https://velopair.org/schemas/0.1/bike.schema.json" }, "compatibility": { "$ref": "https://velopair.org/schemas/0.1/compatibility.schema.json" }, "extensions": { "type": "object", "description": "Producer-specific data under reverse-domain keys. Consumers must ignore unknown extensions.", "additionalProperties": true } }, "anyOf": [ { "required": ["rider"] }, { "required": ["ride_profile"] }, { "required": ["bike"] }, { "required": ["compatibility"] } ] } ``` --- # WORKED EXAMPLE: a bundle for an endurance/gravel rider Fictional bike and engine. Every value is metric. ```json { "spec_version": "0.1.0", "rider": { "spec_version": "0.1.0", "profile_id": "c3a1f7e2-5b8d-4a06-9e21-7d54c2a90b11", "updated_at": "2026-08-17T12:00:00Z", "source": "mixed", "body": { "height_cm": 178, "weight_kg": 76.5, "inseam_cm": 84, "torso_cm": 61, "arm_length_cm": 64, "shoulder_width_cm": 41, "sit_bone_width_mm": 121, "flexibility": "medium" }, "physiology": { "birth_year": 1986, "sex": "unspecified", "resting_hr_bpm": 58, "max_hr_bpm": 184, "ftp_w": 205 }, "intent": { "position_preference": "balanced", "primary_disciplines": ["gravel", "touring"] } }, "ride_profile": { "spec_version": "0.1.0", "profile_id": "9d0e6b31-2f47-4c58-8a3b-e1c9f0724d62", "rider_ref": "c3a1f7e2-5b8d-4a06-9e21-7d54c2a90b11", "derivation": { "method": "measured", "ride_count": 62, "window_days": 180, "generated_at": "2026-08-17T12:00:00Z" }, "volume": { "typical_ride_distance_km": 42.0, "longest_ride_distance_km": 118.0, "weekly_distance_km": 95.0, "rides_per_week": 2.4 }, "pace": { "avg_speed_kmh": 21.4, "typical_duration_min": 118 }, "terrain": { "elevation_gain_m_per_km": 9.4 }, "surface_mix": { "paved_pct": 55, "gravel_pct": 40, "offroad_pct": 5 } }, "bike": { "spec_version": "0.1.0", "profile_id": "5b7c2d90-14ae-4f63-b8d5-3a90c1e67f24", "identity": { "brand": "Example Cycles", "model": "Wanderer AL", "year": 2025, "size": "56", "color": "moss green", "mpn": "EC-WAL-56-MG", "gtin": "4012345678901" }, "instance": { "frame_number": "WEX2025A10432", "odometer_km": 3412 }, "category": "gravel", "motor_assistance": "none", "geometry": { "stack_mm": 591, "reach_mm": 384, "seat_tube_length_mm": 560, "seat_tube_angle_deg": 73.5, "top_tube_length_mm": 565, "head_tube_length_mm": 165, "head_tube_angle_deg": 71.5, "stem_length_mm": 90, "handlebar_width_mm": 440, "wheelbase_mm": 1036, "standover_mm": 792, "chainstay_length_mm": 425, "fork_rake_mm": 50, "bottom_bracket_drop_mm": 76, "crank_length_mm": 172.5 }, "wheels_tires": { "wheel_diameter_etrto_mm": 622, "tire_width_mm": 40, "max_tire_width_mm": 45, "recommended_pressure_bar": 2.4 }, "drivetrain": { "type": "derailleur", "chainring_count": 1, "cassette_speed_count": 11, "chainring_teeth": [40], "cassette_teeth": [11, 13, 15, 17, 19, 21, 24, 28, 32, 36, 42] }, "weight_kg": 9.8 }, "compatibility": { "spec_version": "0.1.0", "engine": { "name": "example-match-engine", "version": "0.1.0", "url": "https://example.invalid/engine" }, "computed_at": "2026-08-17T12:05:00Z", "rider_ref": "c3a1f7e2-5b8d-4a06-9e21-7d54c2a90b11", "ride_profile_ref": "9d0e6b31-2f47-4c58-8a3b-e1c9f0724d62", "bike_ref": "5b7c2d90-14ae-4f63-b8d5-3a90c1e67f24", "score": 91, "confidence": 0.82, "breakdown": [ { "dimension": "size_fit", "score": 94, "note": "Stack 591 / reach 384 (STR 1.54) sits in the balanced-endurance window for 178 cm with 84 cm inseam." }, { "dimension": "use_case_fit", "score": 92, "note": "Measured 40% gravel share matches category; 45 mm clearance covers the measured surface mix." }, { "dimension": "position_fit", "score": 86, "note": "Balanced position preference met; stem could go 10 mm shorter given torso length." } ] } } ``` --- # AUTHORING RULES (condensed) # VeloPair authoring and validation VeloPair is an open data standard: four JSON objects (Rider, Ride Profile, Bike, Compatibility) plus a Bundle container. Spec: https://github.com/aydincan/velopair (SPEC.md). Schemas resolve at https://velopair.org/schemas/0.1/.schema.json for types rider, ride-profile, bike, compatibility, bundle. ## Hard rules (violations make documents nonconforming) 1. Metric only; the unit is part of the field name (`weight_kg`, `stack_mm`, `avg_speed_kmh`). Never write a value in another unit; convert first. 2. Every document carries `spec_version` (currently `"0.1.0"`). 3. Unknown fields are ERRORS (`additionalProperties: false`) everywhere except inside `extensions`, which takes producer data under reverse-domain keys (`"com.example.app": {...}`). 4. Never fabricate values: no invented GTINs, no estimated ride statistics presented as measured. Omit what you do not know; almost everything is optional. 5. No PII in standard fields: no names, contacts, locations, GPS traces. 6. Required minimums: Rider needs `body.height_cm`; Ride Profile needs `derivation.method` (measured | declared | mixed); Bike needs `identity.brand` + `identity.model`; Compatibility needs `engine.name` + `score` (0-100). ## Workflow 1. Start from a skeleton (the `velopair_template` MCP tool if the velopair MCP server is registered, or copy a file from the spec repo's `examples/`). 2. Fill only what is known. Bike product identity vs `instance` (frame number, odometer) are separate blocks; `instance` is sensitive and only included on explicit user request. 3. Validate before delivering, one of: - MCP tool `velopair_validate` (auto-detects the type) - in the spec repo: `node scripts/validate.mjs` (validates everything) - anywhere: fetch the schema from its `$id` URL and check with ajv (draft 2020-12, `strictRequired: false`) 4. For a match score, use the `velopair_match` MCP tool. It runs the open, NON-NORMATIVE reference engine and returns a valid Compatibility document. Never present a score without its `engine` attribution. ## Field quick reference - Rider: `body` (height_cm required; weight_kg, inseam_cm, torso_cm, arm_length_cm, shoulder_width_cm, sit_bone_width_mm, foot_length_cm, flexibility low|medium|high), `physiology` (birth_year, sex, resting_hr_bpm, max_hr_bpm, ftp_w), `intent` (position_preference aggressive|balanced|relaxed, primary_disciplines). - Ride Profile: `derivation` (method, ride_count, window_days, generated_at), `volume` (typical/longest/weekly distance km, rides_per_week), `pace` (avg_speed_kmh, typical_duration_min), `terrain` (elevation_gain_m_per_km), `surface_mix` (paved/gravel/offroad_pct, sum ~100). Omit surface_mix unless actually measured. - Bike: `identity` (brand, model, year, size, color, variant, gtin, mpn), `instance` (frame_number opaque, odometer_km), `category`, `motor_assistance` (none|pedelec|s_pedelec|other), `geometry` (24 mm/deg fields incl. stack_mm, reach_mm), `wheels_tires` (wheel_diameter_etrto_mm, tire_width_mm, max_tire_width_mm, recommended_pressure_bar), `drivetrain`, `weight_kg`, `ebike` (motor_position, motor_power_w, motor_torque_nm, battery_capacity_wh, claimed_range_km). - Compatibility: `engine` (name, version, url), `computed_at`, `*_ref` ids, `score`, `confidence` 0-1, `breakdown[]` (dimension, score, note). - Bundle: `{ "spec_version": ..., "rider": {...}, "ride_profile": {...}, "bike": {...}, "compatibility": {...} }`, at least one member, each member a complete standalone document. Recommended filename `velopair.json`. ## Common mistakes - Imperial values (a 700c wheel is `wheel_diameter_etrto_mm: 622`, not 28). - Putting a size label in geometry (size "56" belongs in `identity.size`). - Normalizing frame numbers (they are opaque; store as engraved). - A bare score with no engine (meaningless under data-not-scores). - Percentages not summing to ~100 in `surface_mix`.