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.
- Initial example workout export.
- 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
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.
@ -16,6 +23,10 @@ A Trainlog exchange document is:
- UTF-8;
- 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
```json
@ -37,17 +48,11 @@ trainlog
### `version`
Integer schema version.
Must equal integer `1`.
For this document:
## 4. Exercise catalog
```text
1
```
## 4. Exercise catalog entries
Each exercise entry contains:
Each catalog entry contains:
```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:
- non-empty;
- unique within the document;
- treated as identity;
- must not change merely because the display name changes.
- ASCII lowercase identifier;
- 1 to 128 characters;
- allowed characters: `a-z`, `0-9`, `_`, `-`;
- 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
@ -79,14 +106,20 @@ Required fields:
- `session_id`;
- `started_at`;
- `ended_at`;
- `exercises`.
Optional body fields may include:
Optional fields:
- `ended_at`;
- `body_weight_kg`;
- `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
Example:
@ -95,25 +128,35 @@ Example:
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
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
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
@ -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
{
"exercise_id": "plank",
"rest_seconds": 60,
"target": {
"sets": 3,
"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:
```json
{
"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
}
}
```text
60
```
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 duplicate its sets;
- do not duplicate sets;
- do not partially merge the repeated document;
- 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;
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.
A valid Trainlog v1 document must pass both:
## 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
Status: IN PROGRESS
Status: VALIDATION PENDING — REVIEW #1
Deliverables:
@ -12,14 +12,22 @@ Deliverables:
- coding-style documentation;
- exchange-format v1 draft;
- JSON Schema draft;
- valid example fixture.
- valid example fixture;
- semantic validator;
- positive and negative fixture suite;
- Gate 0 review report.
Exit criteria:
- 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.
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
Deliverables:
@ -31,13 +39,16 @@ Deliverables:
- rest representation;
- body weight;
- measurement list;
- unknown-field policy;
- invalid fixture suite.
- strict unknown-field policy;
- valid fixture suite;
- invalid fixture suite;
- stable semantic-validation contract.
Exit criteria:
- `TRAINLOG_FORMAT_V1=FROZEN`;
- schema tests pass;
- semantic tests pass;
- Android and TUI can implement against the contract without ambiguity.
## Gate 2 — TUI persistence core
@ -61,10 +72,11 @@ Exit criteria:
Deliverables:
- ncursesw initialization;
- theme module;
- centralized color theme module;
- dashboard shell;
- exercise list;
- session history;
- direct session entry;
- import screen;
- minimum-terminal fallback.

View file

@ -2,38 +2,71 @@
## 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
Trainlog will use:
Trainlog uses or will use:
- JSON Schema validation;
- Trainlog semantic validation;
- unit tests;
- integration tests;
- JSON Schema validation;
- database constraint tests;
- TUI smoke tests;
- 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;
- invalid timestamps;
- invalid negative values;
- empty set data;
- malformed targets;
- unsupported format version.
- duplicate normalized exercise names;
- unknown exercise references;
- duplicate workout exercise entries;
- end timestamp before start timestamp;
- 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;
- duplicate `session_id` is rejected or handled idempotently;
@ -41,9 +74,9 @@ Tests must verify:
- failed imports roll back completely;
- migrations preserve data.
## 5. C validation
## 7. C validation
Initial build validation should include:
Initial C validation will include:
```text
normal build
@ -53,7 +86,7 @@ ASan/UBSan build
Exact commands will be frozen when `meson.build` exists.
## 6. TUI tests
## 8. TUI tests
At minimum:
@ -61,17 +94,18 @@ At minimum:
- small-terminal fallback works;
- navigation does not corrupt state;
- UTF-8 labels render correctly;
- color roles render correctly;
- monochrome fallback remains understandable.
## 7. Pre-push checklist
## 9. Pre-push checklist
Before a meaningful push:
1. format code;
2. build;
3. run tests;
4. validate JSON fixtures;
2. run `python tools/validate_json.py`;
3. build when buildable code exists;
4. run relevant tests;
5. run sanitizer suite when relevant;
6. run `git diff --check`;
7. inspect `git status`;
7. inspect `git status --short`;
8. update documentation.

View file

@ -34,6 +34,12 @@
"maxLength": 128,
"pattern": "^[a-z0-9][a-z0-9_-]*$"
},
"sessionId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
"exerciseCatalogEntry": {
"type": "object",
"additionalProperties": false,
@ -72,17 +78,27 @@
"maximum": 5000
}
},
"anyOf": [
"oneOf": [
{
"required": [
"reps"
],
"not": {
"required": [
"duration_seconds"
]
}
},
{
"required": [
"duration_seconds"
],
"not": {
"required": [
"reps"
]
}
}
]
},
"target": {
@ -113,17 +129,27 @@
"maximum": 5000
}
},
"anyOf": [
"oneOf": [
{
"required": [
"reps"
],
"not": {
"required": [
"duration_seconds"
]
}
},
{
"required": [
"duration_seconds"
],
"not": {
"required": [
"reps"
]
}
}
]
},
"sessionExercise": {
@ -160,6 +186,7 @@
"measurements": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"waist_cm": {
"type": "number",
@ -214,14 +241,11 @@
"required": [
"session_id",
"started_at",
"ended_at",
"exercises"
],
"properties": {
"session_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
"$ref": "#/$defs/sessionId"
},
"started_at": {
"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
"""Validate Trainlog JSON fixtures against the canonical JSON Schema."""
"""Validate Trainlog JSON documents structurally and semantically."""
from __future__ import annotations
import argparse
import json
import sys
import unicodedata
from datetime import datetime
from pathlib import Path
from typing import Any
try:
import jsonschema
except ImportError:
print(
"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,
)
raise SystemExit(2)
ROOT = Path(__file__).resolve().parents[1]
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."""
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def main(argv: list[str]) -> int:
"""Validate one or more Trainlog JSON files."""
schema = load_json(SCHEMA_PATH)
def normalize_exercise_name(name: str) -> str:
"""Return the canonical comparison form used for duplicate-name checks.
targets = [Path(arg) for arg in argv[1:]]
if not targets:
targets = [ROOT / "examples" / "session-v1.json"]
The serialized display name is never rewritten by this function. The
normalized value exists only for semantic identity checks.
"""
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(
schema,
format_checker=jsonschema.FormatChecker(),
)
validator.check_schema(schema)
if not args.paths:
return run_suite(validator)
failed = False
for path in targets:
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))
for path in args.paths:
errors = validate_document(validator, path)
if errors:
print(f"FAIL {path}")
for error in errors:
location = ".".join(str(part) for part in error.absolute_path)
if not location:
location = "<root>"
print(f" {location}: {error.message}")
print(f" {error}")
failed = True
else:
print(f"PASS {path}")