Harden Trainlog v1 exchange contract

This commit is contained in:
fy59 2026-09-05 19:13:53 +02:00
parent ca080d8145
commit bc54d6b4ce
17 changed files with 1100 additions and 145 deletions

View file

@ -16,3 +16,15 @@ The project uses a simple pre-release changelog during early development.
- JSON Schema draft for Trainlog v1. - JSON Schema draft for Trainlog v1.
- Initial example workout export. - Initial example workout export.
- Database, TUI, Android, testing, and roadmap documentation. - Database, TUI, Android, testing, and roadmap documentation.
- Trainlog semantic JSON validator.
- Positive and negative exchange-format fixtures.
- Gate 0 review #1 report.
### Changed
- `ended_at` is optional for active or interrupted sessions.
- Repetition and timed exercise modes are now mutually exclusive.
- Exercise display-name anti-duplication semantics are defined.
- Session/catalog cross-reference rules are executable.
- Timestamp offset and chronology rules are executable.
- Unknown fields are explicitly rejected in v1.

View file

@ -2,9 +2,16 @@
## 1. Status ## 1. Status
This document defines the initial Trainlog v1 exchange contract. This document defines the Trainlog v1 exchange contract draft.
Until explicitly marked `FROZEN`, incompatible changes are allowed during early development. Current state:
```text
TRAINLOG_FORMAT_V1=DRAFT
GATE_0=VALIDATION_PENDING
```
Incompatible changes are allowed until the format is explicitly marked `FROZEN`.
Once frozen, incompatible changes require a new format version. Once frozen, incompatible changes require a new format version.
@ -16,6 +23,10 @@ A Trainlog exchange document is:
- UTF-8; - UTF-8;
- one top-level JSON object. - one top-level JSON object.
Unknown fields are rejected in v1.
This strict rule is intentional: a misspelled or unsupported field must fail validation rather than be silently ignored.
## 3. Required top-level fields ## 3. Required top-level fields
```json ```json
@ -37,17 +48,11 @@ trainlog
### `version` ### `version`
Integer schema version. Must equal integer `1`.
For this document: ## 4. Exercise catalog
```text Each catalog entry contains:
1
```
## 4. Exercise catalog entries
Each exercise entry contains:
```json ```json
{ {
@ -56,22 +61,44 @@ Each exercise entry contains:
} }
``` ```
### `exercise_id` ### 4.1 Stable identity
Stable identifier. `exercise_id` is the permanent machine identifier.
Rules: Rules:
- non-empty; - ASCII lowercase identifier;
- unique within the document; - 1 to 128 characters;
- treated as identity; - allowed characters: `a-z`, `0-9`, `_`, `-`;
- must not change merely because the display name changes. - unique within one exchange document;
- must not change merely because the visible name changes.
### `name` The visible name is not the persistent identity.
Human-readable display name. ### 4.2 Display-name anti-duplication rule
The TUI may update the local display name later without changing `exercise_id`. Two catalog entries must not have equivalent display names.
For duplicate detection, implementations normalize names using this semantic algorithm:
1. Unicode NFC normalization;
2. remove leading and trailing whitespace;
3. collapse each internal run of whitespace to one ASCII space;
4. Unicode case folding.
Example:
```text
"Presse à cuisses"
" presse à cuisses "
"PRESSE À CUISSES"
```
are considered the same display name.
This rule prevents accidental duplicate exercises while still allowing an exercise to be renamed without changing `exercise_id`.
JSON Schema cannot express this normalization rule. It is mandatory semantic validation.
## 5. Session ## 5. Session
@ -79,14 +106,20 @@ Required fields:
- `session_id`; - `session_id`;
- `started_at`; - `started_at`;
- `ended_at`;
- `exercises`. - `exercises`.
Optional body fields may include: Optional fields:
- `ended_at`;
- `body_weight_kg`; - `body_weight_kg`;
- `measurements`. - `measurements`.
`ended_at` is optional because Trainlog may preserve an active or interrupted session.
A completed Android export normally includes `ended_at`.
The TUI must never invent an end timestamp for a session that does not have one.
## 6. Session identifier ## 6. Session identifier
Example: Example:
@ -95,25 +128,35 @@ Example:
20260905-183412-a84c 20260905-183412-a84c
``` ```
The exact generation algorithm is implementation-defined in v1. Rules:
The invariant is uniqueness. - 1 to 128 characters;
- starts with an ASCII alphanumeric character;
- remaining characters are ASCII alphanumeric, `_`, or `-`;
- treated as an opaque unique identifier.
The TUI must enforce uniqueness at import. The generation algorithm is implementation-defined in v1.
The TUI enforces uniqueness in SQLite.
Repeated import of the same `session_id` is idempotent.
## 7. Timestamps ## 7. Timestamps
Timestamps use ISO 8601 with an explicit UTC offset. Timestamps use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset.
Example: Examples:
```text ```text
2026-09-05T18:34:12+02:00 2026-09-05T18:34:12+02:00
2026-09-05T16:34:12Z
``` ```
The timezone offset is part of the serialized value. An offset-less timestamp is invalid.
The TUI must not silently reinterpret a timestamp as local time without using the encoded offset. If `ended_at` is present, it must represent an instant strictly later than `started_at`.
Chronological ordering is a semantic validation rule.
## 8. Workout exercise entry ## 8. Workout exercise entry
@ -137,83 +180,160 @@ Example:
} }
``` ```
The `target` object describes the intended work. `target` describes intended work.
The `sets` array describes what was actually performed. `sets` describes actual performed work.
These two concepts must remain distinct. The two concepts must remain distinct.
## 9. Timed exercises The number of actual sets is deliberately allowed to differ from `target.sets`.
Timed exercises such as planks use `duration_seconds`. This records failure, extra work, interrupted sessions, and manual corrections truthfully.
Example: ## 9. Repetition mode and timed mode
Each workout exercise has exactly one target mode:
- repetition mode: `reps`;
- timed mode: `duration_seconds`.
A target must not contain both.
All actual sets for that exercise must use the same mode as the target.
### Repetition example
```json
{
"target": {
"sets": 4,
"reps": 5
}
}
```
### Timed example
```json ```json
{ {
"exercise_id": "plank",
"rest_seconds": 60,
"target": { "target": {
"sets": 3, "sets": 3,
"duration_seconds": 45 "duration_seconds": 45
}, }
"sets": [
{ "duration_seconds": 45 },
{ "duration_seconds": 45 },
{ "duration_seconds": 38 }
]
} }
``` ```
A set may represent repetitions or duration. The schema rejects a set or target containing both `reps` and `duration_seconds`.
The schema forbids an empty set object. The target/actual mode-match rule is semantic validation.
## 10. Body measurements ## 10. Load
The initial v1 measurement object supports named measurements in centimeters. `weight_kg` represents external load in kilograms.
It is optional because some exercises are bodyweight or duration-only exercises.
When supplied, actual-set load is recorded per set so a session can truthfully represent load changes between sets.
## 11. Rest
`rest_seconds` is the planned rest duration after sets for the workout exercise.
Example: Example:
```json ```text
{ 60
"measurements": {
"waist_cm": 91.0,
"chest_cm": 104.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
}
}
``` ```
The initial schema intentionally uses explicit field names rather than arbitrary free-form keys. means one minute.
Additional measurements may be added before v1 is frozen. v1 does not record measured rest duration per individual set.
## 11. Idempotent import That may be introduced only by an additive compatible extension before freeze or a later format version after freeze.
The TUI must treat `session_id` as a uniqueness key. ## 12. Body weight
If a session has already been imported: `body_weight_kg` is optional and uses kilograms.
It represents body weight associated with the session.
Standalone body-weight observations outside a workout session are a TUI/database concern and do not require this session exchange object.
## 13. Body measurements
Supported v1 measurements use centimeters:
- `waist_cm`;
- `chest_cm`;
- `shoulders_cm`;
- `left_arm_cm`;
- `right_arm_cm`;
- `left_thigh_cm`;
- `right_thigh_cm`;
- `left_calf_cm`;
- `right_calf_cm`.
If `measurements` is present, it must contain at least one measurement.
Additional measurements may still be added before v1 is frozen.
## 14. Catalog references
Every `session.exercises[*].exercise_id` must reference an entry present in the top-level `exercises` catalog.
This permits Android to introduce a new exercise safely during import.
An unknown reference makes the document invalid.
JSON Schema cannot express this cross-reference rule. It is mandatory semantic validation.
## 15. Duplicate workout exercise entries
A session must not contain the same `exercise_id` more than once.
All performed sets for one exercise belong to its single workout entry.
This keeps analysis and editing deterministic.
## 16. Idempotent import
The TUI treats `session_id` as a uniqueness key.
If the session is already present:
- do not create another session; - do not create another session;
- do not duplicate its sets; - do not duplicate sets;
- do not partially merge the repeated document;
- report that the session already exists. - report that the session already exists.
## 12. New exercises Database constraints are the final anti-duplication barrier.
When Android exports a session containing an exercise unknown to the TUI: ## 17. Validation layers
1. the exercise must exist in the top-level `exercises` array; A valid Trainlog v1 document must pass both:
2. its `exercise_id` must be valid;
3. its `name` must be non-empty;
4. the TUI imports the catalog entry;
5. the session may then reference that exercise.
## 13. Unknown fields 1. JSON Schema validation;
2. Trainlog semantic validation.
Before v1 is frozen, implementations may reject unknown fields during development to catch mistakes early. Schema validation handles structure and primitive bounds.
The final forward-compatibility policy will be frozen explicitly before release. Semantic validation handles rules such as:
- normalized exercise-name uniqueness;
- exercise identifier uniqueness;
- catalog-reference integrity;
- duplicate workout exercise rejection;
- target/actual mode consistency;
- timestamp chronology.
Both Android export and TUI import must eventually implement the same semantic contract.
## 18. Freeze policy
`TRAINLOG_FORMAT_V1` must not be marked `FROZEN` until:
- all v1 fields are reviewed;
- valid fixtures pass;
- invalid fixtures fail for the intended reason;
- Android and TUI requirements contain no format ambiguity;
- the semantic validator contract is stable.

View file

@ -0,0 +1,128 @@
# Gate 0 Review #1
## Status
```text
GATE_0_REVIEW_01=IMPLEMENTED
GATE_0=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
## Scope
This review hardens the initial project contract before any C17 or Android implementation begins.
## Findings corrected
### G0-R1-01 — Completed timestamp was structurally mandatory
The initial schema required `ended_at`.
That contradicted the architecture requirement to preserve an active or interrupted session without inventing a completion time.
Correction:
- `ended_at` is optional;
- when present, semantic validation requires it to be strictly later than `started_at`.
### G0-R1-02 — Repetitions and duration were not exclusive
The initial schema used `anyOf`.
A target or set containing both `reps` and `duration_seconds` therefore satisfied both alternatives and could be accepted.
Correction:
- schema uses an exclusive representation;
- every target/set contains exactly one activity mode.
### G0-R1-03 — Exercise identifier uniqueness was undocumented executable behavior
JSON Schema cannot enforce uniqueness of one property across different objects in an array.
Correction:
- semantic validator rejects duplicate `exercise_id` values;
- negative fixture added.
### G0-R1-04 — Display-name duplicates could create duplicate exercises
Stable identifiers alone do not prevent accidental creation of two exercises with visually equivalent names.
Correction:
- semantic normalization algorithm documented;
- semantic validator rejects duplicate normalized display names;
- negative fixture added.
### G0-R1-05 — Session exercise references were not checked against the catalog
JSON Schema cannot validate this cross-reference.
Correction:
- semantic validator requires every session exercise to exist in the top-level catalog;
- negative fixture added.
### G0-R1-06 — Duplicate exercise entries inside one workout were ambiguous
A session could contain the same exercise twice, complicating editing and analytics.
Correction:
- v1 requires one workout entry per `exercise_id`;
- all actual sets belong to that entry;
- negative fixture added.
### G0-R1-07 — Target and actual set modes could disagree
A repetition target could contain duration-based actual sets or vice versa.
Correction:
- semantic validator enforces mode consistency;
- negative fixture added.
### G0-R1-08 — Timestamp offset and chronology required semantic enforcement
The contract requires explicit timezone information and meaningful ordering.
Correction:
- validator rejects offset-less timestamps;
- validator rejects `ended_at <= started_at`;
- negative fixtures added.
### G0-R1-09 — Unknown-field behavior was not frozen
Silently accepting misspelled fields would risk data loss.
Correction:
- v1 draft explicitly rejects unknown fields;
- schema keeps `additionalProperties: false`;
- negative fixture added.
## Validation command
```bash
python tools/validate_json.py
git diff --check
```
Expected result:
- every valid fixture reports `PASS valid`;
- every invalid fixture reports `PASS invalid`;
- `git diff --check` prints nothing.
## Gate decision
Review #1 does not itself mark Gate 0 as PASS.
Gate 0 becomes eligible for PASS after:
1. the canonical local validation succeeds;
2. the review is committed;
3. the commit is pushed to Forgejo and GitHub;
4. the mirrored repository is reviewed.

View file

@ -2,7 +2,7 @@
## Gate 0 — Project contract ## Gate 0 — Project contract
Status: IN PROGRESS Status: VALIDATION PENDING — REVIEW #1
Deliverables: Deliverables:
@ -12,14 +12,22 @@ Deliverables:
- coding-style documentation; - coding-style documentation;
- exchange-format v1 draft; - exchange-format v1 draft;
- JSON Schema draft; - JSON Schema draft;
- valid example fixture. - valid example fixture;
- semantic validator;
- positive and negative fixture suite;
- Gate 0 review report.
Exit criteria: Exit criteria:
- documentation reviewed; - documentation reviewed;
- JSON example validates against the schema; - JSON example validates;
- valid fixtures are accepted;
- invalid fixtures are rejected;
- semantic invariants are documented;
- repository clean after commit. - repository clean after commit.
Gate 0 is not PASS until the review #1 validation commands pass in the canonical working tree and the resulting commit is pushed.
## Gate 1 — Exchange format v1 freeze ## Gate 1 — Exchange format v1 freeze
Deliverables: Deliverables:
@ -31,13 +39,16 @@ Deliverables:
- rest representation; - rest representation;
- body weight; - body weight;
- measurement list; - measurement list;
- unknown-field policy; - strict unknown-field policy;
- invalid fixture suite. - valid fixture suite;
- invalid fixture suite;
- stable semantic-validation contract.
Exit criteria: Exit criteria:
- `TRAINLOG_FORMAT_V1=FROZEN`; - `TRAINLOG_FORMAT_V1=FROZEN`;
- schema tests pass; - schema tests pass;
- semantic tests pass;
- Android and TUI can implement against the contract without ambiguity. - Android and TUI can implement against the contract without ambiguity.
## Gate 2 — TUI persistence core ## Gate 2 — TUI persistence core
@ -61,10 +72,11 @@ Exit criteria:
Deliverables: Deliverables:
- ncursesw initialization; - ncursesw initialization;
- theme module; - centralized color theme module;
- dashboard shell; - dashboard shell;
- exercise list; - exercise list;
- session history; - session history;
- direct session entry;
- import screen; - import screen;
- minimum-terminal fallback. - minimum-terminal fallback.

View file

@ -2,38 +2,71 @@
## 1. Principle ## 1. Principle
A feature is not complete without its relevant validation. A feature is not complete without relevant validation.
The exchange-format validator is part of the executable contract during early development.
## 2. Validation layers ## 2. Validation layers
Trainlog will use: Trainlog uses or will use:
- JSON Schema validation;
- Trainlog semantic validation;
- unit tests; - unit tests;
- integration tests; - integration tests;
- JSON Schema validation;
- database constraint tests; - database constraint tests;
- TUI smoke tests; - TUI smoke tests;
- sanitizer builds where practical. - sanitizer builds where practical.
## 3. Exchange-format tests ## 3. Exchange-format validation
The repository must contain valid and invalid fixtures. Run the canonical suite with:
Valid fixtures must pass the schema. ```bash
python tools/validate_json.py
```
Invalid fixtures should cover: The command validates:
- `examples/session-v1.json`;
- every file in `tests/fixtures/valid/` as valid;
- every file in `tests/fixtures/invalid/` as invalid.
A negative fixture passes only when validation rejects it.
## 4. Structural versus semantic validation
JSON Schema validates structure and primitive bounds.
`tools/validate_json.py` additionally validates rules JSON Schema cannot safely express, including:
- unique `exercise_id` values;
- normalized display-name uniqueness;
- catalog-reference integrity;
- one workout entry per exercise;
- target/actual mode consistency;
- explicit timestamp offsets;
- end-time chronology.
Android export and TUI import must eventually implement the same semantic rules.
## 5. Initial invalid fixture coverage
The Gate 0 suite covers:
- missing required fields;
- duplicate exercise identifiers; - duplicate exercise identifiers;
- invalid timestamps; - duplicate normalized exercise names;
- invalid negative values; - unknown exercise references;
- empty set data; - duplicate workout exercise entries;
- malformed targets; - end timestamp before start timestamp;
- unsupported format version. - offset-less timestamp;
- target/actual mode mismatch;
- target containing both repetitions and duration;
- unknown JSON field.
## 4. Database tests ## 6. Database tests
Tests must verify: Future tests must verify:
- foreign keys are active; - foreign keys are active;
- duplicate `session_id` is rejected or handled idempotently; - duplicate `session_id` is rejected or handled idempotently;
@ -41,9 +74,9 @@ Tests must verify:
- failed imports roll back completely; - failed imports roll back completely;
- migrations preserve data. - migrations preserve data.
## 5. C validation ## 7. C validation
Initial build validation should include: Initial C validation will include:
```text ```text
normal build normal build
@ -53,7 +86,7 @@ ASan/UBSan build
Exact commands will be frozen when `meson.build` exists. Exact commands will be frozen when `meson.build` exists.
## 6. TUI tests ## 8. TUI tests
At minimum: At minimum:
@ -61,17 +94,18 @@ At minimum:
- small-terminal fallback works; - small-terminal fallback works;
- navigation does not corrupt state; - navigation does not corrupt state;
- UTF-8 labels render correctly; - UTF-8 labels render correctly;
- color roles render correctly;
- monochrome fallback remains understandable. - monochrome fallback remains understandable.
## 7. Pre-push checklist ## 9. Pre-push checklist
Before a meaningful push: Before a meaningful push:
1. format code; 1. format code;
2. build; 2. run `python tools/validate_json.py`;
3. run tests; 3. build when buildable code exists;
4. validate JSON fixtures; 4. run relevant tests;
5. run sanitizer suite when relevant; 5. run sanitizer suite when relevant;
6. run `git diff --check`; 6. run `git diff --check`;
7. inspect `git status`; 7. inspect `git status --short`;
8. update documentation. 8. update documentation.

View file

@ -34,6 +34,12 @@
"maxLength": 128, "maxLength": 128,
"pattern": "^[a-z0-9][a-z0-9_-]*$" "pattern": "^[a-z0-9][a-z0-9_-]*$"
}, },
"sessionId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
"exerciseCatalogEntry": { "exerciseCatalogEntry": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -72,17 +78,27 @@
"maximum": 5000 "maximum": 5000
} }
}, },
"anyOf": [ "oneOf": [
{ {
"required": [ "required": [
"reps" "reps"
],
"not": {
"required": [
"duration_seconds"
] ]
}
}, },
{ {
"required": [ "required": [
"duration_seconds" "duration_seconds"
],
"not": {
"required": [
"reps"
] ]
} }
}
] ]
}, },
"target": { "target": {
@ -113,17 +129,27 @@
"maximum": 5000 "maximum": 5000
} }
}, },
"anyOf": [ "oneOf": [
{ {
"required": [ "required": [
"reps" "reps"
],
"not": {
"required": [
"duration_seconds"
] ]
}
}, },
{ {
"required": [ "required": [
"duration_seconds" "duration_seconds"
],
"not": {
"required": [
"reps"
] ]
} }
}
] ]
}, },
"sessionExercise": { "sessionExercise": {
@ -160,6 +186,7 @@
"measurements": { "measurements": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1,
"properties": { "properties": {
"waist_cm": { "waist_cm": {
"type": "number", "type": "number",
@ -214,14 +241,11 @@
"required": [ "required": [
"session_id", "session_id",
"started_at", "started_at",
"ended_at",
"exercises" "exercises"
], ],
"properties": { "properties": {
"session_id": { "session_id": {
"type": "string", "$ref": "#/$defs/sessionId"
"minLength": 1,
"maxLength": 128
}, },
"started_at": { "started_at": {
"type": "string", "type": "string",

View file

@ -0,0 +1,44 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
},
{
"exercise_id": "leg_press",
"name": "Autre presse"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,44 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
},
{
"exercise_id": "leg_press_alt",
"name": " PRESSE À CUISSES "
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,59 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
},
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,40 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T17:59:59+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,39 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"duration_seconds": 30
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,41 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80,
"duration_seconds": 30
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,40 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,40 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "unknown_exercise",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
]
}
}

View file

@ -0,0 +1,41 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "leg_press",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
}
]
}
],
"unexpected": "typo"
}
}

View file

@ -0,0 +1,32 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "plank",
"name": "Gainage ventral"
}
],
"session": {
"session_id": "20260905-190000-active1",
"started_at": "2026-09-05T19:00:00+02:00",
"exercises": [
{
"exercise_id": "plank",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -1,63 +1,268 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Validate Trainlog JSON fixtures against the canonical JSON Schema.""" """Validate Trainlog JSON documents structurally and semantically."""
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import sys import sys
import unicodedata
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
try: try:
import jsonschema import jsonschema
except ImportError: except ImportError:
print( print(
"error: missing Python dependency 'jsonschema'\n" "error: missing Python dependency 'jsonschema'\n"
"install it with: python -m pip install --user jsonschema", "Arch Linux: sudo pacman -S python-jsonschema",
file=sys.stderr, file=sys.stderr,
) )
raise SystemExit(2) raise SystemExit(2)
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json" SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
def load_json(path: Path) -> object: class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates Trainlog semantics."""
def load_json(path: Path) -> Any:
"""Load one UTF-8 JSON file and return its decoded value.""" """Load one UTF-8 JSON file and return its decoded value."""
with path.open("r", encoding="utf-8") as handle: with path.open("r", encoding="utf-8") as handle:
return json.load(handle) return json.load(handle)
def main(argv: list[str]) -> int: def normalize_exercise_name(name: str) -> str:
"""Validate one or more Trainlog JSON files.""" """Return the canonical comparison form used for duplicate-name checks.
schema = load_json(SCHEMA_PATH)
targets = [Path(arg) for arg in argv[1:]] The serialized display name is never rewritten by this function. The
if not targets: normalized value exists only for semantic identity checks.
targets = [ROOT / "examples" / "session-v1.json"] """
nfc = unicodedata.normalize("NFC", name)
collapsed = " ".join(nfc.strip().split())
return collapsed.casefold()
def parse_timestamp(value: str, field_name: str) -> datetime:
"""Parse a Trainlog timestamp while requiring an explicit UTC offset."""
candidate = value
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError as exc:
raise TrainlogSemanticError(
f"{field_name}: invalid date-time: {value!r}"
) from exc
if parsed.utcoffset() is None:
raise TrainlogSemanticError(
f"{field_name}: UTC offset is required: {value!r}"
)
return parsed
def validate_semantics(document: dict[str, Any]) -> None:
"""Validate cross-field and normalized Trainlog v1 invariants."""
catalog = document["exercises"]
session = document["session"]
exercise_ids: set[str] = set()
normalized_names: dict[str, str] = {}
for index, exercise in enumerate(catalog):
exercise_id = exercise["exercise_id"]
name = exercise["name"]
if exercise_id in exercise_ids:
raise TrainlogSemanticError(
f"exercises[{index}].exercise_id: duplicate exercise_id "
f"{exercise_id!r}"
)
exercise_ids.add(exercise_id)
normalized = normalize_exercise_name(name)
if not normalized:
raise TrainlogSemanticError(
f"exercises[{index}].name: name is empty after normalization"
)
previous_id = normalized_names.get(normalized)
if previous_id is not None:
raise TrainlogSemanticError(
f"exercises[{index}].name: normalized name duplicates exercise "
f"{previous_id!r}"
)
normalized_names[normalized] = exercise_id
started_at = parse_timestamp(session["started_at"], "session.started_at")
if "ended_at" in session:
ended_at = parse_timestamp(session["ended_at"], "session.ended_at")
if ended_at <= started_at:
raise TrainlogSemanticError(
"session.ended_at: must be strictly later than session.started_at"
)
workout_ids: set[str] = set()
for index, workout in enumerate(session["exercises"]):
exercise_id = workout["exercise_id"]
if exercise_id not in exercise_ids:
raise TrainlogSemanticError(
f"session.exercises[{index}].exercise_id: unknown catalog "
f"reference {exercise_id!r}"
)
if exercise_id in workout_ids:
raise TrainlogSemanticError(
f"session.exercises[{index}].exercise_id: duplicate workout "
f"exercise {exercise_id!r}"
)
workout_ids.add(exercise_id)
target = workout["target"]
target_mode = (
"reps" if "reps" in target else "duration_seconds"
)
for set_index, actual_set in enumerate(workout["sets"]):
actual_mode = (
"reps" if "reps" in actual_set else "duration_seconds"
)
if actual_mode != target_mode:
raise TrainlogSemanticError(
f"session.exercises[{index}].sets[{set_index}]: "
f"actual mode {actual_mode!r} does not match target mode "
f"{target_mode!r}"
)
def structural_errors(
validator: jsonschema.Draft202012Validator,
document: Any,
) -> list[str]:
"""Return deterministic human-readable JSON Schema errors."""
errors = sorted(
validator.iter_errors(document),
key=lambda error: [str(part) for part in error.absolute_path],
)
rendered: list[str] = []
for error in errors:
location = ".".join(str(part) for part in error.absolute_path)
if not location:
location = "<root>"
rendered.append(f"{location}: {error.message}")
return rendered
def validate_document(
validator: jsonschema.Draft202012Validator,
path: Path,
) -> list[str]:
"""Return all validation errors for one Trainlog document."""
try:
document = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
return [str(exc)]
errors = structural_errors(validator, document)
if errors:
return errors
try:
validate_semantics(document)
except TrainlogSemanticError as exc:
return [str(exc)]
return []
def discover_suite() -> tuple[list[Path], list[Path]]:
"""Return repository fixtures with their expected validation result."""
valid = [ROOT / "examples" / "session-v1.json"]
valid.extend(sorted(VALID_FIXTURE_DIR.glob("*.json")))
invalid = sorted(INVALID_FIXTURE_DIR.glob("*.json"))
return valid, invalid
def run_suite(validator: jsonschema.Draft202012Validator) -> int:
"""Validate all positive and negative repository fixtures."""
valid, invalid = discover_suite()
failed = False
if not invalid:
print("FAIL test suite: no invalid fixtures found")
return 1
for path in valid:
errors = validate_document(validator, path)
if errors:
print(f"FAIL expected valid: {path}")
for error in errors:
print(f" {error}")
failed = True
else:
print(f"PASS valid: {path}")
for path in invalid:
errors = validate_document(validator, path)
if not errors:
print(f"FAIL expected invalid: {path}")
failed = True
else:
print(f"PASS invalid: {path}")
print(f" rejected: {errors[0]}")
return 1 if failed else 0
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Validate Trainlog v1 JSON structurally and semantically."
)
parser.add_argument(
"paths",
nargs="*",
type=Path,
help="documents expected to be valid; omit to run the repository suite",
)
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
"""Validate explicit documents or run the canonical fixture suite."""
args = parse_args(argv)
schema = load_json(SCHEMA_PATH)
validator = jsonschema.Draft202012Validator( validator = jsonschema.Draft202012Validator(
schema, schema,
format_checker=jsonschema.FormatChecker(), format_checker=jsonschema.FormatChecker(),
) )
validator.check_schema(schema)
if not args.paths:
return run_suite(validator)
failed = False failed = False
for path in args.paths:
for path in targets: errors = validate_document(validator, path)
try:
document = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
print(f"FAIL {path}: {exc}")
failed = True
continue
errors = sorted(validator.iter_errors(document), key=lambda e: list(e.path))
if errors: if errors:
print(f"FAIL {path}") print(f"FAIL {path}")
for error in errors: for error in errors:
location = ".".join(str(part) for part in error.absolute_path) print(f" {error}")
if not location:
location = "<root>"
print(f" {location}: {error.message}")
failed = True failed = True
else: else:
print(f"PASS {path}") print(f"PASS {path}")