Define Trainlog v1 freeze candidate

This commit is contained in:
fy59 2026-09-05 19:22:37 +02:00
parent ebd4316ed6
commit 9d9a9223a0
32 changed files with 2135 additions and 471 deletions

View file

@ -8,24 +8,24 @@ The project uses a simple pre-release changelog during early development.
### Added
- Initial repository structure.
- Development contract.
- Architecture documentation.
- Coding-style rules.
- Exchange-format v1 draft.
- JSON Schema draft for Trainlog v1.
- Initial example workout export.
- Database, TUI, Android, testing, and roadmap documentation.
- Trainlog semantic JSON validator.
- Initial repository structure and development contract.
- Architecture, coding-style, database, Android, TUI, testing, and roadmap documentation.
- Trainlog v1 JSON Schema draft.
- Structural and semantic Trainlog validator.
- Positive and negative exchange-format fixtures.
- Gate 0 review #1 report.
- Gate 0 review and closure records.
- Gate 1 exchange-format freeze-candidate review.
- Stable exercise tracking modes.
- Explicit load modes for no load, external resistance, and assistance.
- Optional bounded session and exercise notes.
- Extended body measurement list.
### Changed
- Gate 0 project contract review completed successfully.
- `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.
- Gate 0 project contract is complete.
- Gate 1 is the active format-freeze gate.
- Actual repetition count may be zero for a failed attempt.
- Planned exercises may contain zero actual sets.
- Top-level exercise metadata must exactly match the session exercise references.
- Assistance kilograms have distinct semantics from external resistance.
- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 validation and mirrored review.

View file

@ -6,9 +6,9 @@ The Android application is a lightweight training-session recorder.
Its design priority is low-friction data entry during a workout.
## 2. Session flow
It is not the canonical history or analytics application.
Expected flow:
## 2. Session flow
```text
Start session
@ -17,13 +17,16 @@ Start session
record started_at
|
v
add exercises and targets
select/create exercise
|
v
enter target + planned rest
|
v
record actual sets
|
v
optional body data
optional body data / notes
|
v
Finish session
@ -32,45 +35,68 @@ Finish session
record ended_at
|
v
export Trainlog JSON
validate + export Trainlog JSON
```
## 3. Exercise catalog
The application maintains a local exercise catalog for selection.
The Android application keeps a local exercise catalog so names are not retyped every session.
The user must not need to retype the same exercise every session.
Creating an exercise requires:
When a new exercise is created:
- display name;
- tracking mode: repetitions or duration.
- generate a stable `exercise_id`;
- store the display name;
- use that same identifier in future sessions;
- include the exercise catalog entry in exported files as required.
The application generates a stable `exercise_id`.
## 4. Planned work
A new exercise used in an exported session is included in the top-level session export metadata and is therefore importable by the TUI.
The basic exercise form supports:
The Android application must prevent accidental duplicate normalized names according to the Trainlog v1 contract.
- number of sets;
- repetitions or timed duration;
- load when relevant;
- rest duration.
## 4. Fast exercise form
Example:
For a repetition exercise, the basic form is conceptually:
```text
4 sets
5 repetitions
80 kg
60 seconds rest
Exercise Presse à cuisses
Load mode External
Load 80 kg
Sets 4
Repetitions 5
Rest 60 s
```
## 5. Actual work
For a timed exercise:
The UI should pre-populate performed sets from the target when convenient.
```text
Exercise Gainage ventral
Load mode None
Sets 3
Duration 45 s
Rest 60 s
```
The user only needs to edit differences.
The application should remember practical defaults from the previous use of an exercise when that reduces typing, but remembered UI defaults are not part of the exchange-format contract.
## 5. Load modes
The user chooses only when relevant:
- none;
- external;
- assistance.
`external` covers free weights and machine-displayed load.
`assistance` stores a positive assistance value.
The UI should label assistance explicitly so it cannot be confused with added resistance.
## 6. Actual work
The application should pre-populate actual sets from the target.
The user edits only what differs.
Example target:
@ -84,30 +110,71 @@ Actual:
5 / 5 / 5 / 3
```
The exported file must preserve both target and actual values.
The export preserves both target and actual values.
## 6. Timestamps
Zero actual repetitions are valid for a real failed attempt.
A planned exercise may also have zero actual sets if it was never started.
## 7. Rest
`rest_seconds` is the planned rest duration for the exercise.
v1 does not require a running rest timer and does not serialize measured per-set rest.
A timer can be added later as UI behavior without changing the v1 format.
## 8. Timestamps
`started_at` is recorded automatically when the session starts.
`ended_at` is recorded automatically when the session ends.
`ended_at` is recorded automatically when the user finishes the session.
The UI may later allow explicit correction for forgotten starts or stops, but such correction must be visible to the user.
An active/interrupted local session may exist without `ended_at`.
## 7. Export
The application must never invent an end timestamp merely to make export validation pass.
The application exports valid Trainlog JSON.
## 9. Body data
It must not silently export malformed or incomplete data.
Optional session-associated data:
The application must validate required fields before final export.
- body weight;
- neck;
- shoulders;
- chest;
- waist;
- hips;
- left/right arm;
- left/right forearm;
- left/right thigh;
- left/right calf.
## 8. Non-goals
The Android UI does not need to force these fields during every workout.
## 10. Notes
Session and exercise notes are optional.
The initial Android UI may omit note controls without violating v1, because the fields are optional.
## 11. Export
Before export, Android must enforce both:
- JSON structural validity;
- Trainlog v1 semantic validity.
A malformed or semantically inconsistent file must not be exported as a completed Trainlog document.
## 12. Non-goals
Initial Android versions do not need:
- advanced analytics;
- complex charts;
- a cloud account;
- a remote database;
- social features.
- analytics;
- complex graphs;
- cloud accounts;
- remote databases;
- social features;
- muscle classification;
- distance/cardio metrics;
- per-set rest measurement.

View file

@ -2,32 +2,53 @@
## 1. Status
This document defines the Trainlog v1 exchange contract draft.
Current state:
```text
TRAINLOG_FORMAT_V1=DRAFT
GATE_0=PASS
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
Incompatible changes are allowed until the format is explicitly marked `FROZEN`.
This document defines the proposed final Trainlog v1 exchange contract.
Once frozen, incompatible changes require a new format version.
The format remains `DRAFT` until Gate 1 validation and mirror review complete.
## 2. Encoding
## 2. Design goal
A Trainlog exchange document is:
Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform.
The format supports:
- repetition-based exercises;
- time-based exercises;
- bodyweight work;
- free-weight and machine load;
- assistance load;
- planned versus performed work;
- planned rest;
- body weight;
- body measurements;
- active or interrupted sessions;
- optional notes.
Distance, velocity, heart rate, per-set measured rest, supersets, and arbitrary custom metrics are outside v1.
## 3. Encoding and strictness
A Trainlog v1 document is:
- JSON;
- UTF-8;
- one top-level JSON object.
- one top-level object;
- structurally strict.
Unknown fields are rejected in v1.
Unknown fields are rejected.
This strict rule is intentional: a misspelled or unsupported field must fail validation rather than be silently ignored.
This is intentional. A misspelled field must fail validation instead of being silently discarded.
## 3. Required top-level fields
## 4. Top-level object
Required fields:
```json
{
@ -38,89 +59,78 @@ This strict rule is intentional: a misspelled or unsupported field must fail val
}
```
### `format`
`format` must equal `trainlog`.
Must equal:
`version` must equal integer `1`.
```text
trainlog
```
The top-level `exercises` array contains metadata for exactly the exercises referenced by the session.
### `version`
It is not a full catalog synchronization document.
Must equal integer `1`.
## 4. Exercise catalog
## 5. Exercise identity
Each catalog entry contains:
```json
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
}
```
### 4.1 Stable identity
### 5.1 `exercise_id`
`exercise_id` is the permanent machine identifier.
`exercise_id` is the permanent machine identity.
Rules:
- 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.
- ASCII lowercase;
- first character: `a-z` or `0-9`;
- remaining characters: `a-z`, `0-9`, `_`, `-`;
- unique inside the document;
- unchanged when the visible name changes.
The visible name is not the persistent identity.
### 5.2 `name`
### 4.2 Display-name anti-duplication rule
`name` is the human-readable display name.
Two catalog entries must not have equivalent display names.
The display name is not the identity.
For duplicate detection, implementations normalize names using this semantic algorithm:
Two exercises in one document must not have equivalent normalized names.
Normalization for comparison is:
1. Unicode NFC normalization;
2. remove leading and trailing whitespace;
3. collapse each internal run of whitespace to one ASCII space;
2. trim leading and trailing Unicode whitespace;
3. collapse each internal whitespace run to one ASCII space;
4. Unicode case folding.
Example:
The serialized name is never rewritten by this normalization rule.
The future C implementation must use a Unicode implementation capable of reproducing this contract exactly; `utf8proc` or an equivalent tested implementation is acceptable.
### 5.3 `tracking_mode`
Every exercise has one stable tracking mode:
```text
"Presse à cuisses"
" presse à cuisses "
"PRESSE À CUISSES"
reps
duration
```
are considered the same display name.
`reps` is used for repetition-counted exercises.
This rule prevents accidental duplicate exercises while still allowing an exercise to be renamed without changing `exercise_id`.
`duration` is used for time-counted exercises such as planks.
JSON Schema cannot express this normalization rule. It is mandatory semantic validation.
The tracking mode determines the Android input control and the interpretation of all sets for that exercise.
## 5. Session
Changing the fundamental tracking mode of an existing exercise should normally create a new exercise identity rather than silently changing historical semantics.
Required fields:
## 6. Session identity
- `session_id`;
- `started_at`;
- `exercises`.
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
A session contains a unique opaque `session_id`.
Example:
@ -132,18 +142,21 @@ Rules:
- 1 to 128 characters;
- starts with an ASCII alphanumeric character;
- remaining characters are ASCII alphanumeric, `_`, or `-`;
- treated as an opaque unique identifier.
- remaining characters are ASCII alphanumeric, `_`, or `-`.
The generation algorithm is implementation-defined in v1.
The generation algorithm remains implementation-defined.
The TUI enforces uniqueness in SQLite.
The database uniqueness constraint is the final anti-duplication barrier.
Repeated import of the same `session_id` is idempotent.
Importing an already-known `session_id` is idempotent.
## 7. Timestamps
## 7. Session timestamps
Timestamps use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset.
`started_at` is required.
`ended_at` is optional.
Both use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset.
Examples:
@ -152,19 +165,32 @@ Examples:
2026-09-05T16:34:12Z
```
An offset-less timestamp is invalid.
Offset-less timestamps are invalid.
If `ended_at` is present, it must represent an instant strictly later than `started_at`.
If `ended_at` exists, it must represent an instant strictly later than `started_at`.
Chronological ordering is a semantic validation rule.
An absent `ended_at` means the session is still active, interrupted, or otherwise not formally completed.
## 8. Workout exercise entry
Trainlog must not invent an end time.
Example:
## 8. Session exercise order
The order of `session.exercises` is meaningful.
It records the exercise order entered by the user.
The order of `sets` is also meaningful and defines performed set order.
No separate set number is serialized.
## 9. Planned work and performed work
Each session exercise contains:
```json
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -184,60 +210,123 @@ Example:
`sets` describes actual performed work.
The two concepts must remain distinct.
They must remain separate.
The number of actual sets is deliberately allowed to differ from `target.sets`.
`target.sets` is the intended number of sets.
This records failure, extra work, interrupted sessions, and manual corrections truthfully.
The length of `sets` is the actual number of recorded sets and may be:
## 9. Repetition mode and timed mode
- smaller than the target;
- equal to the target;
- greater than the target;
- zero.
Each workout exercise has exactly one target mode:
An empty actual-set array is valid for a planned exercise that was not performed.
- repetition mode: `reps`;
- timed mode: `duration_seconds`.
## 10. Repetition mode
A target must not contain both.
For a `tracking_mode` of `reps`:
All actual sets for that exercise must use the same mode as the target.
- `target.reps` is required;
- `target.duration_seconds` is forbidden;
- each actual set contains `reps`;
- each actual set forbids `duration_seconds`.
### Repetition example
Target repetitions must be at least 1.
```json
{
"target": {
"sets": 4,
"reps": 5
}
}
Actual repetitions may be 0.
A zero-repetition set represents a real attempted set with no completed repetition.
A skipped set should normally be omitted instead.
## 11. Duration mode
For a `tracking_mode` of `duration`:
- `target.duration_seconds` is required;
- `target.reps` is forbidden;
- each actual set contains `duration_seconds`;
- each actual set forbids `reps`.
Duration values are positive integer seconds.
## 12. Load model
Each session exercise has exactly one `load_mode`:
```text
none
external
assistance
```
### Timed example
### 12.1 `none`
```json
{
"target": {
"sets": 3,
"duration_seconds": 45
}
}
Use for exercises where no separate load value is recorded.
Examples:
- bodyweight squat;
- unweighted plank;
- push-up;
- pull-up without added or assisted load.
When `load_mode` is `none`, `weight_kg` is forbidden in the target and actual sets.
### 12.2 `external`
Use for a positive externally applied or machine-displayed load.
Examples:
- barbell;
- dumbbell;
- cable machine;
- leg press;
- weighted pull-up.
When `load_mode` is `external`:
- `target.weight_kg` is required;
- every actual set requires its own `weight_kg`.
Actual set load is stored per set so load changes remain representable.
### 12.3 `assistance`
Use when the numeric load represents assistance that reduces the effective difficulty of a bodyweight movement.
Example:
```text
Assisted pull-up: 20 kg assistance
```
The schema rejects a set or target containing both `reps` and `duration_seconds`.
When `load_mode` is `assistance`:
The target/actual mode-match rule is semantic validation.
- `target.weight_kg` is required;
- every actual set requires its own `weight_kg`.
## 10. Load
Assistance remains a positive value.
`weight_kg` represents external load in kilograms.
Analytics must not treat increasing assistance as increasing strength.
It is optional because some exercises are bodyweight or duration-only exercises.
### 12.4 Unit and physical meaning
When supplied, actual-set load is recorded per set so a session can truthfully represent load changes between sets.
All serialized loads use kilograms.
## 11. Rest
For a machine, `weight_kg` records the load value displayed or declared by the machine/user.
`rest_seconds` is the planned rest duration after sets for the workout exercise.
Trainlog does not claim that this value equals exact mechanical force at the body.
This distinction matters when comparing different machines.
## 13. Rest
`rest_seconds` is required for every session exercise.
It stores the planned rest interval in integer seconds.
Example:
@ -247,93 +336,147 @@ Example:
means one minute.
v1 does not record measured rest duration per individual set.
Zero is valid when no planned rest exists.
That may be introduced only by an additive compatible extension before freeze or a later format version after freeze.
v1 does not record measured rest between individual sets.
## 12. Body weight
## 14. Body weight
`body_weight_kg` is optional and uses kilograms.
`body_weight_kg` is optional.
It represents body weight associated with the session.
It is a positive kilogram value associated with the session timestamp.
Standalone body-weight observations outside a workout session are a TUI/database concern and do not require this session exchange object.
The TUI database may also support standalone body-weight observations; those records are outside this session-exchange document.
## 13. Body measurements
## 15. Body measurements
Supported v1 measurements use centimeters:
`measurements` is optional.
- `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 present, it contains at least one measurement.
If `measurements` is present, it must contain at least one measurement.
All measurements are circumferences in centimeters unless the field name itself defines another interpretation.
Additional measurements may still be added before v1 is frozen.
Frozen v1 measurement names proposed by Gate 1 review #1:
## 14. Catalog references
```text
neck_cm
shoulders_cm
chest_cm
waist_cm
hips_cm
left_arm_cm
right_arm_cm
left_forearm_cm
right_forearm_cm
left_thigh_cm
right_thigh_cm
left_calf_cm
right_calf_cm
```
Every `session.exercises[*].exercise_id` must reference an entry present in the top-level `exercises` catalog.
`shoulders_cm` means shoulder-girdle circumference, not straight-line shoulder width.
This permits Android to introduce a new exercise safely during import.
Left/right fields are intentionally separate so asymmetry can be followed over time.
An unknown reference makes the document invalid.
## 16. Notes
JSON Schema cannot express this cross-reference rule. It is mandatory semantic validation.
Optional UTF-8 notes are supported at:
## 15. Duplicate workout exercise entries
- session level;
- session-exercise level.
A session must not contain the same `exercise_id` more than once.
A present note must not be empty or whitespace-only.
All performed sets for one exercise belong to its single workout entry.
Session notes are limited to 4000 characters.
This keeps analysis and editing deterministic.
Exercise notes are limited to 1000 characters.
## 16. Idempotent import
Notes are user content and must be preserved exactly after validation.
The TUI treats `session_id` as a uniqueness key.
## 17. Catalog completeness
If the session is already present:
The top-level exercise catalog must contain exactly the exercise identities referenced by `session.exercises`.
- do not create another session;
- do not duplicate sets;
- do not partially merge the repeated document;
- report that the session already exists.
Therefore:
Database constraints are the final anti-duplication barrier.
- every session exercise has one matching catalog entry;
- no catalog entry is unreferenced;
- an empty session exercise array requires an empty top-level exercise catalog.
## 17. Validation layers
This keeps each session export self-contained without silently importing unrelated Android catalog entries.
A valid Trainlog v1 document must pass both:
## 18. One workout entry per exercise
A `session_id` may contain a given `exercise_id` at most once.
All performed sets for that exercise belong to its single session-exercise entry.
This makes editing and analytics deterministic.
## 19. Existing local exercise with different display name
Identity wins over display text.
If the TUI already knows an `exercise_id` and an imported session carries a different display name for that same identifier:
- the session may still import;
- the existing canonical TUI identity is used;
- the import must surface a non-fatal metadata warning;
- the session import must not silently rename the canonical local exercise.
Catalog synchronization and deliberate renaming are separate operations outside the v1 session-import transaction.
## 20. Structural and semantic validation
A valid v1 document passes both:
1. JSON Schema validation;
2. Trainlog semantic validation.
Schema validation handles structure and primitive bounds.
Semantic rules include:
Semantic validation handles rules such as:
- unique exercise identifiers;
- normalized display-name uniqueness;
- explicit timestamp offsets;
- timestamp chronology;
- exact catalog/reference set equality;
- one workout entry per exercise;
- catalog `tracking_mode` matching target and actual sets;
- `load_mode` matching weight presence;
- non-blank notes.
- normalized exercise-name uniqueness;
- exercise identifier uniqueness;
- catalog-reference integrity;
- duplicate workout exercise rejection;
- target/actual mode consistency;
- timestamp chronology.
## 21. Explicit non-goals for v1
Both Android export and TUI import must eventually implement the same semantic contract.
The following are deliberately not represented by v1:
## 18. Freeze policy
- distance;
- speed;
- velocity;
- heart rate;
- calories;
- measured per-set rest;
- supersets/circuits as first-class objects;
- arbitrary custom set metrics;
- muscle-group classification;
- machine seat/settings metadata;
- photos;
- cloud synchronization.
`TRAINLOG_FORMAT_V1` must not be marked `FROZEN` until:
Those features can be introduced later without corrupting the simple initial recorder.
- 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.
## 22. Freeze criteria
Gate 1 may freeze v1 only after:
- canonical valid fixtures pass;
- canonical invalid fixtures fail for the intended reason;
- Android requirements are aligned;
- TUI requirements are aligned;
- the schema and semantic contract contain no known ambiguity;
- the reviewed commit is pushed and mirrored.
Until then:
```text
TRAINLOG_FORMAT_V1=DRAFT
```

View file

@ -0,0 +1,146 @@
# Gate 1 Review #1 — Exchange Format Freeze Candidate
## Status
```text
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
## Purpose
Gate 1 review #1 turns the Gate 0 format skeleton into a complete v1 implementation contract.
No C17 or Android production implementation should depend on v1 until this review passes and the format is explicitly frozen.
## Decisions
### G1-R1-01 — Stable exercise tracking mode
Each exercise declares exactly one stable `tracking_mode`:
```text
reps
duration
```
This keeps Android and TUI forms deterministic.
### G1-R1-02 — Load semantics belong to the session exercise
A given movement may be bodyweight, externally loaded, or assisted on different sessions.
Therefore `load_mode` belongs to the session exercise, not permanently to the catalog.
Frozen candidates:
```text
none
external
assistance
```
### G1-R1-03 — Assistance is not resistance
Positive assistance kilograms are stored as positive values.
Analytics must interpret higher assistance as more help, not more strength.
### G1-R1-04 — Actual load is per set
Loaded exercises require `weight_kg` on every performed set.
This permits truthful representation of drop sets, changed machine loads, and failed attempts.
### G1-R1-05 — Failed zero-repetition attempts are representable
Actual `reps` may equal zero.
Target `reps` remain strictly positive.
A skipped set is omitted; zero represents an actual attempt with no completed repetition.
### G1-R1-06 — Planned exercise with no actual sets is representable
`sets` may be empty.
This supports interrupted sessions and exercises planned but never started.
### G1-R1-07 — Session and set order are array order
No redundant serialized ordinal is needed.
Array order is canonical order.
### G1-R1-08 — Measurement list is explicit
The proposed final v1 list is:
- neck;
- shoulders;
- chest;
- waist;
- hips;
- left/right arm;
- left/right forearm;
- left/right thigh;
- left/right calf.
### G1-R1-09 — Notes are optional and bounded
Session notes: maximum 4000 characters.
Exercise notes: maximum 1000 characters.
Whitespace-only notes are semantically invalid.
### G1-R1-10 — Catalog metadata is session-scoped, not full synchronization
The top-level catalog contains exactly the exercise IDs referenced by the exported session.
This prevents an ordinary session import from silently importing unrelated Android catalog entries.
### G1-R1-11 — Existing ID/name mismatch is non-fatal
Stable identity wins over display metadata.
A session may import using an existing ID while surfacing a metadata warning.
Session import does not silently rename the canonical local exercise.
### G1-R1-12 — v1 non-goals are explicit
Distance, cardio telemetry, custom metrics, supersets, machine settings, muscle classification, and measured per-set rest are intentionally deferred.
## Validation
Run:
```bash
python tools/validate_json.py
git diff --check
```
The suite must include valid examples of:
- loaded repetition exercise;
- unweighted timed exercise;
- active session;
- zero-repetition failed attempt;
- assistance load;
- empty interrupted session.
The invalid suite must cover all frozen semantic boundaries.
## Freeze decision
This review is a freeze candidate, not the freeze itself.
After canonical validation, commit, push, and mirrored review, a separate closure patch may set:
```text
GATE_1_REVIEW_01=PASS
GATE_1=PASS
TRAINLOG_FORMAT_V1=FROZEN
```

View file

@ -11,59 +11,67 @@ GATE_0=PASS
GATE_0_REVIEW_01=PASS
```
Deliverables:
Gate 0 established:
- repository structure;
- development contract;
- architecture documentation;
- coding-style documentation;
- exchange-format v1 draft;
- JSON Schema draft;
- valid example fixture;
- semantic validator;
- positive and negative fixture suite;
- Gate 0 review report.
- coding-style rules;
- exchange-format draft;
- structural and semantic validation;
- positive and negative fixture strategy.
Exit criteria:
Reviewed hardening commit:
- documentation reviewed;
- JSON example validates;
- valid fixtures are accepted;
- invalid fixtures are rejected;
- semantic invariants are documented;
- repository clean after commit.
```text
bc54d6b4ce10d098916823b6a79f72b39d9c7703
```
Gate 0 passed after:
Gate 0 closure commit:
- canonical local fixture validation succeeded;
- `git diff --check` succeeded;
- review commit `bc54d6b4ce10d098916823b6a79f72b39d9c7703` was pushed;
- the GitHub mirror was independently read back and reviewed.
Gate 1 is now the active gate.
```text
ebd4316ed68c58598a471e567edf13455d00f92b
```
## Gate 1 — Exchange format v1 freeze
Deliverables:
Status: VALIDATION PENDING — REVIEW #1
- complete field list;
- exercise identity rules;
- session identity rules;
- repetitions and timed-set representation;
- rest representation;
Canonical state:
```text
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
Review #1 freezes the proposed model for:
- exercise identity;
- stable repetition/duration tracking mode;
- session identity;
- session ordering;
- external/none/assistance load semantics;
- planned versus actual sets;
- zero-repetition failed attempts;
- planned exercises with zero actual sets;
- planned rest;
- body weight;
- measurement list;
- strict unknown-field policy;
- valid fixture suite;
- invalid fixture suite;
- stable semantic-validation contract.
- final v1 body-measurement field list;
- optional notes;
- strict catalog completeness;
- strict unknown-field behavior.
Exit criteria:
- `TRAINLOG_FORMAT_V1=FROZEN`;
- schema tests pass;
- semantic tests pass;
- Android and TUI can implement against the contract without ambiguity.
- all valid fixtures accepted;
- all invalid fixtures rejected for the intended reason;
- Android documentation aligned;
- TUI documentation aligned;
- schema and semantic validator aligned;
- review commit pushed to Forgejo and GitHub;
- mirrored review passes;
- `TRAINLOG_FORMAT_V1=FROZEN`.
## Gate 2 — TUI persistence core

View file

@ -4,23 +4,11 @@
A feature is not complete without relevant validation.
The exchange-format validator is part of the executable contract during early development.
The exchange validator is executable specification during the format gates.
## 2. Validation layers
## 2. Canonical exchange validation
Trainlog uses or will use:
- JSON Schema validation;
- Trainlog semantic validation;
- unit tests;
- integration tests;
- database constraint tests;
- TUI smoke tests;
- sanitizer builds where practical.
## 3. Exchange-format validation
Run the canonical suite with:
Run:
```bash
python tools/validate_json.py
@ -29,83 +17,97 @@ python tools/validate_json.py
The command validates:
- `examples/session-v1.json`;
- every file in `tests/fixtures/valid/` as valid;
- every file in `tests/fixtures/invalid/` as invalid.
- all `tests/fixtures/valid/*.json` as valid;
- all `tests/fixtures/invalid/*.json` as invalid.
A negative fixture passes only when validation rejects it.
A negative fixture passes only when Trainlog rejects it.
## 4. Structural versus semantic validation
## 3. Validation layers
JSON Schema validates structure and primitive bounds.
A v1 document must pass:
`tools/validate_json.py` additionally validates rules JSON Schema cannot safely express, including:
1. JSON Schema validation;
2. Trainlog semantic validation.
- unique `exercise_id` values;
- normalized display-name uniqueness;
- catalog-reference integrity;
- one workout entry per exercise;
- target/actual mode consistency;
Schema handles shape, enumerations, and primitive ranges.
Semantic validation handles cross-object and normalized rules.
## 4. Gate 1 semantic coverage
The canonical validator checks:
- unique `exercise_id`;
- normalized exercise-name uniqueness;
- explicit timestamp offsets;
- end-time chronology.
- end time later than start time;
- exact catalog/reference set equality;
- one workout entry per exercise;
- catalog tracking mode matching target;
- catalog tracking mode matching actual sets;
- load-mode/weight consistency;
- non-blank notes.
Android export and TUI import must eventually implement the same semantic rules.
## 5. Positive fixture coverage
## 5. Initial invalid fixture coverage
Gate 1 includes:
The Gate 0 suite covers:
- mixed loaded repetition + timed session;
- active session without `ended_at`;
- planned exercise with zero actual sets;
- bodyweight exercise with zero-repetition failed attempt;
- assistance load;
- completely interrupted session with zero exercises.
- duplicate exercise identifiers;
## 6. Negative fixture coverage
Gate 1 includes rejection of:
- duplicate exercise IDs;
- duplicate normalized exercise names;
- unknown exercise references;
- duplicate workout exercise entries;
- end timestamp before start timestamp;
- offset-less timestamp;
- target/actual mode mismatch;
- end timestamp before start;
- missing timestamp offset;
- target/actual tracking mismatch;
- target containing both repetitions and duration;
- unknown JSON field.
- unknown exercise reference;
- unknown JSON field;
- catalog tracking-mode mismatch;
- `load_mode=none` carrying weight;
- loaded target missing weight;
- loaded actual set missing weight;
- unreferenced catalog entries;
- blank session notes;
- blank exercise notes;
- negative actual repetitions.
## 6. Database tests
## 7. Future database validation
Future tests must verify:
Gate 2 must verify:
- foreign keys are active;
- duplicate `session_id` is rejected or handled idempotently;
- duplicate `exercise_id` is rejected;
- failed imports roll back completely;
- migrations preserve data.
- foreign keys enabled;
- duplicate `session_id` idempotency;
- duplicate `exercise_id` barrier;
- transactional rollback;
- schema migration correctness.
## 7. C validation
## 8. Future C validation
Initial C validation will include:
C implementation gates will include:
```text
normal build
strict warning build
ASan/UBSan build
```
Exact commands will be frozen when `meson.build` exists.
## 8. TUI tests
At minimum:
- application starts in a supported terminal;
- small-terminal fallback works;
- navigation does not corrupt state;
- UTF-8 labels render correctly;
- color roles render correctly;
- monochrome fallback remains understandable.
- normal build;
- strict-warning build;
- ASan/UBSan build;
- formatter check;
- relevant unit/integration tests.
## 9. Pre-push checklist
Before a meaningful push:
Before every meaningful push:
1. format code;
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 --short`;
8. update documentation.
1. run `python tools/validate_json.py`;
2. run relevant compiled tests when available;
3. run sanitizers when relevant;
4. run `git diff --check`;
5. inspect `git status --short`;
6. review documentation changes.

View file

@ -2,7 +2,7 @@
## 1. Purpose
The Trainlog TUI is the primary history, analysis, and visualization application.
The Trainlog TUI is the primary local history, analysis, and visualization application.
It is implemented in C17 with `ncursesw`.
@ -17,30 +17,28 @@ Initial screen plan:
- Body;
- Import.
The final key bindings will be frozen before implementation.
## 3. Color
The TUI should be visually rich but remain readable.
The TUI is intentionally colorful.
Color is used to reinforce meaning, never as the only indicator.
Color reinforces meaning but is never the sole indicator.
Conceptual roles:
- accent: titles, active selection, highlighted metrics;
- accent: titles and current selection;
- success: completed target;
- warning: partial target or attention state;
- error: invalid input or failed operation;
- muted: secondary information;
- graph series: consistent distinguishable colors.
All color pairs must be centralized in a theme module.
All color pairs must be centralized in a dedicated theme module.
Do not scatter raw `COLOR_*` decisions throughout screens.
Raw screen code must not scatter `COLOR_*` decisions.
## 4. Monochrome fallback
Every meaningful color state must also have a textual or symbolic representation.
Meaningful states also use text or symbols.
Examples:
@ -51,60 +49,98 @@ x failed
> selected
```
## 5. UTF-8
## 5. UTF-8 and exercise-name normalization
The TUI uses wide-character ncurses support.
The TUI uses wide-character ncurses support and initializes locale before ncurses.
The implementation must initialize locale correctly before ncurses use.
Trainlog v1 duplicate-name validation requires Unicode NFC normalization, whitespace normalization, and Unicode case folding.
Rendering must be tested with accented French text and common symbols.
The C implementation must use a tested Unicode library or equivalent implementation that reproduces the v1 contract exactly.
## 6. Dashboard
A likely implementation dependency is `utf8proc`; the final dependency choice is frozen before the relevant C module is implemented.
## 6. Exercise catalog
Each exercise stores:
- stable `exercise_id`;
- mutable display name;
- stable `tracking_mode` (`reps` or `duration`).
The TUI uses this metadata to select the correct data-entry control.
A session import may introduce a previously unknown exercise.
If an existing ID arrives with a different display name, the session may import but the TUI must surface a metadata warning and must not silently rename the canonical local exercise.
## 7. New session
The TUI can record a workout directly using the same logical exercise model as Android.
Per exercise:
- exercise;
- load mode;
- target sets;
- target repetitions or duration;
- target load when applicable;
- planned rest;
- actual sets;
- optional note.
## 8. Load semantics
The TUI must distinguish:
- no separate load;
- external resistance;
- assistance.
Analytics must not rank assistance as though more assistance represented more strength.
Machine-displayed kilograms are stored faithfully but must not be presented as exact cross-machine mechanical equivalence.
## 9. Dashboard
The dashboard should eventually show:
- current body weight;
- recent body-weight change;
- number of sessions in a selected period;
- recent weight change;
- sessions in a selected period;
- total training duration;
- recent performance highlights;
- compact terminal graphs.
## 7. New session
## 10. Body tracking
The TUI must be able to record a workout directly, using the same logical exercise catalog as Android.
The TUI database may store standalone body observations independently from workout imports.
A typical exercise form contains:
The session exchange format can also attach weight and measurements to one session timestamp.
- exercise;
- target sets;
- target repetitions or duration;
- target load when relevant;
- rest duration;
- actual performed sets.
Body-trend graphs operate on the canonical database representation, not directly on raw JSON files.
## 8. Graphs
## 11. Graphs
Graphs must be terminal-native.
Terminal-native graph targets include:
Possible graph types:
- weight trend;
- body-weight trend;
- measurement trend;
- exercise load trend;
- estimated or measured max trend;
- training volume trend.
- external-load trend;
- measured or estimated maximum trend;
- training-volume trend.
Graphs should adapt to terminal size.
Assistance exercises require direction-aware analytics.
## 9. Minimum terminal size
## 12. Minimum terminal size
A minimum supported terminal size will be defined during the first TUI milestone.
Below the minimum size, Trainlog must display a clear message instead of rendering a broken layout.
Below that size, Trainlog displays a clear fallback message rather than a corrupted layout.
## 10. Input safety
## 13. Input safety
The TUI must validate numeric fields before committing data.
Numeric input is validated before persistent state is committed.
Invalid input must not partially modify persistent state.
Invalid input must never partially mutate a saved session.
Imports use full validation before the database transaction commits.

View file

@ -4,11 +4,13 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
@ -17,14 +19,19 @@
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"waist_cm": 91.0,
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"notes": "Séance jambes et gainage.",
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -52,6 +59,7 @@
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,

View file

@ -1,19 +1,24 @@
# Trainlog Format
This directory contains machine-readable definitions of the Trainlog exchange format.
This directory contains the machine-readable Trainlog exchange contract.
Current draft:
Current canonical state:
- `trainlog-v1.schema.json`
```text
GATE_0=PASS
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
Canonical human-readable semantics live in:
Files:
- `docs/exchange_format.md`
- `trainlog-v1.schema.json`: structural JSON Schema;
- `../docs/exchange_format.md`: canonical semantic specification;
- `../tools/validate_json.py`: executable structural + semantic validator.
The schema is a structural validator.
The JSON Schema alone is not the complete Trainlog contract.
Semantic rules that JSON Schema cannot safely express remain documented and must be tested in application code.
A document is valid only when it passes both structural and semantic validation.
Before Trainlog v1 is frozen, schema changes are allowed.
After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new version.
After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new exchange-format version.

View file

@ -19,6 +19,7 @@
},
"exercises": {
"type": "array",
"maxItems": 1000,
"items": {
"$ref": "#/$defs/exerciseCatalogEntry"
}
@ -40,12 +41,18 @@
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
},
"note": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"exerciseCatalogEntry": {
"type": "object",
"additionalProperties": false,
"required": [
"exercise_id",
"name"
"name",
"tracking_mode"
],
"properties": {
"exercise_id": {
@ -55,16 +62,23 @@
"type": "string",
"minLength": 1,
"maxLength": 200
},
"tracking_mode": {
"type": "string",
"enum": [
"reps",
"duration"
]
}
}
},
"set": {
"actualSet": {
"type": "object",
"additionalProperties": false,
"properties": {
"reps": {
"type": "integer",
"minimum": 1,
"minimum": 0,
"maximum": 10000
},
"duration_seconds": {
@ -74,7 +88,7 @@
},
"weight_kg": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": 0,
"maximum": 5000
}
},
@ -125,7 +139,7 @@
},
"weight_kg": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": 0,
"maximum": 5000
}
},
@ -157,6 +171,7 @@
"additionalProperties": false,
"required": [
"exercise_id",
"load_mode",
"rest_seconds",
"target",
"sets"
@ -165,6 +180,14 @@
"exercise_id": {
"$ref": "#/$defs/exerciseId"
},
"load_mode": {
"type": "string",
"enum": [
"none",
"external",
"assistance"
]
},
"rest_seconds": {
"type": "integer",
"minimum": 0,
@ -175,11 +198,15 @@
},
"sets": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"$ref": "#/$defs/set"
"$ref": "#/$defs/actualSet"
}
},
"notes": {
"type": "string",
"minLength": 1,
"maxLength": 1000
}
}
},
@ -188,7 +215,12 @@
"additionalProperties": false,
"minProperties": 1,
"properties": {
"waist_cm": {
"neck_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"shoulders_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
@ -198,7 +230,12 @@
"exclusiveMinimum": 0,
"maximum": 500
},
"shoulders_cm": {
"waist_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"hips_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
@ -213,6 +250,16 @@
"exclusiveMinimum": 0,
"maximum": 500
},
"left_forearm_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"right_forearm_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"left_thigh_cm": {
"type": "number",
"exclusiveMinimum": 0,
@ -263,9 +310,11 @@
"measurements": {
"$ref": "#/$defs/measurements"
},
"notes": {
"$ref": "#/$defs/note"
},
"exercises": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"$ref": "#/$defs/sessionExercise"

View file

@ -0,0 +1,82 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
],
"notes": "\t\n"
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,82 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
],
"notes": " "
}
}

View file

@ -4,24 +4,38 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
},
{
"exercise_id": "leg_press",
"name": "Autre presse"
"name": "Presse à cuisses",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -36,6 +50,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -4,24 +4,38 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
},
{
"exercise_id": "leg_press_alt",
"name": " PRESSE À CUISSES "
"name": " PRESSE À CUISSES ",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -36,6 +50,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -32,11 +45,40 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
},
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -51,6 +93,14 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T17:59:59+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T17:00:00+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -32,6 +45,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -0,0 +1,80 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,80 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": -1,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -26,11 +39,40 @@
},
"sets": [
{
"duration_seconds": 30
"duration_seconds": 30,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -33,6 +46,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -32,6 +45,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "duration"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "unknown_exercise",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -32,6 +45,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -4,20 +4,33 @@
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses"
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "fixture-session-1",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T19:00:00+02:00",
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
@ -32,6 +45,34 @@
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}

View file

@ -0,0 +1,86 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
},
{
"exercise_id": "unused",
"name": "Exercice inutilisé",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,7 +4,8 @@
"exercises": [
{
"exercise_id": "plank",
"name": "Gainage ventral"
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
@ -13,19 +14,13 @@
"exercises": [
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
"sets": []
}
]
}

View file

@ -0,0 +1,42 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "assisted_pull_up",
"name": "Traction assistée",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "assisted-pull-up",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T18:15:00+02:00",
"exercises": [
{
"exercise_id": "assisted_pull_up",
"load_mode": "assistance",
"rest_seconds": 90,
"target": {
"sets": 3,
"reps": 8,
"weight_kg": 20
},
"sets": [
{
"reps": 8,
"weight_kg": 20
},
{
"reps": 8,
"weight_kg": 20
},
{
"reps": 6,
"weight_kg": 20
}
]
}
]
}
}

View file

@ -0,0 +1,32 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "pull_up",
"name": "Traction",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "zero-rep-attempt",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T18:10:00+02:00",
"exercises": [
{
"exercise_id": "pull_up",
"load_mode": "none",
"rest_seconds": 120,
"target": {
"sets": 1,
"reps": 1
},
"sets": [
{
"reps": 0
}
]
}
]
}
}

View file

@ -0,0 +1,11 @@
{
"format": "trainlog",
"version": 1,
"exercises": [],
"session": {
"session_id": "interrupted-before-first-exercise",
"started_at": "2026-09-05T18:00:00+02:00",
"notes": "Séance interrompue avant le premier exercice.",
"exercises": []
}
}

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Validate Trainlog JSON documents structurally and semantically."""
"""Validate Trainlog v1 JSON documents structurally and semantically."""
from __future__ import annotations
@ -29,7 +29,7 @@ INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates Trainlog semantics."""
"""Raised when structurally valid JSON violates Trainlog v1 semantics."""
def load_json(path: Path) -> Any:
@ -39,11 +39,7 @@ def load_json(path: Path) -> Any:
def normalize_exercise_name(name: str) -> str:
"""Return the canonical comparison form used for duplicate-name checks.
The serialized display name is never rewritten by this function. The
normalized value exists only for semantic identity checks.
"""
"""Return the canonical comparison form for duplicate-name validation."""
nfc = unicodedata.normalize("NFC", name)
collapsed = " ".join(nfc.strip().split())
return collapsed.casefold()
@ -70,24 +66,96 @@ def parse_timestamp(value: str, field_name: str) -> datetime:
return parsed
def require_non_blank(value: str, field_name: str) -> None:
"""Reject a note that contains only Unicode whitespace."""
if not value.strip():
raise TrainlogSemanticError(f"{field_name}: must not be blank")
def validate_load_mode(
workout: dict[str, Any],
workout_index: int,
) -> None:
"""Validate load-mode rules for one session exercise."""
load_mode = workout["load_mode"]
target = workout["target"]
actual_sets = workout["sets"]
target_has_weight = "weight_kg" in target
if load_mode == "none":
if target_has_weight:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target.weight_kg: "
"forbidden when load_mode is 'none'"
)
for set_index, actual_set in enumerate(actual_sets):
if "weight_kg" in actual_set:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]."
"weight_kg: forbidden when load_mode is 'none'"
)
return
if not target_has_weight:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target.weight_kg: "
f"required when load_mode is {load_mode!r}"
)
for set_index, actual_set in enumerate(actual_sets):
if "weight_kg" not in actual_set:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]."
f"weight_kg: required when load_mode is {load_mode!r}"
)
def validate_tracking_mode(
workout: dict[str, Any],
workout_index: int,
tracking_mode: str,
) -> None:
"""Validate target and actual-set metric fields against catalog mode."""
target = workout["target"]
target_mode = "reps" if "reps" in target else "duration"
if target_mode != tracking_mode:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target: mode "
f"{target_mode!r} does not match catalog tracking_mode "
f"{tracking_mode!r}"
)
for set_index, actual_set in enumerate(workout["sets"]):
actual_mode = "reps" if "reps" in actual_set else "duration"
if actual_mode != tracking_mode:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]: "
f"mode {actual_mode!r} does not match catalog tracking_mode "
f"{tracking_mode!r}"
)
def validate_semantics(document: dict[str, Any]) -> None:
"""Validate cross-field and normalized Trainlog v1 invariants."""
"""Validate Trainlog v1 cross-field and normalized invariants."""
catalog = document["exercises"]
session = document["session"]
exercise_ids: set[str] = set()
catalog_by_id: dict[str, dict[str, Any]] = {}
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:
if exercise_id in catalog_by_id:
raise TrainlogSemanticError(
f"exercises[{index}].exercise_id: duplicate exercise_id "
f"{exercise_id!r}"
)
exercise_ids.add(exercise_id)
catalog_by_id[exercise_id] = exercise
normalized = normalize_exercise_name(name)
if not normalized:
@ -112,12 +180,16 @@ def validate_semantics(document: dict[str, Any]) -> None:
"session.ended_at: must be strictly later than session.started_at"
)
if "notes" in session:
require_non_blank(session["notes"], "session.notes")
workout_ids: set[str] = set()
for index, workout in enumerate(session["exercises"]):
exercise_id = workout["exercise_id"]
if exercise_id not in exercise_ids:
catalog_entry = catalog_by_id.get(exercise_id)
if catalog_entry is None:
raise TrainlogSemanticError(
f"session.exercises[{index}].exercise_id: unknown catalog "
f"reference {exercise_id!r}"
@ -130,20 +202,30 @@ def validate_semantics(document: dict[str, Any]) -> None:
)
workout_ids.add(exercise_id)
target = workout["target"]
target_mode = (
"reps" if "reps" in target else "duration_seconds"
validate_tracking_mode(
workout,
index,
catalog_entry["tracking_mode"],
)
validate_load_mode(workout, index)
if "notes" in workout:
require_non_blank(
workout["notes"],
f"session.exercises[{index}].notes",
)
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:
catalog_ids = set(catalog_by_id)
if catalog_ids != workout_ids:
unreferenced = sorted(catalog_ids - workout_ids)
missing = sorted(workout_ids - catalog_ids)
details: list[str] = []
if unreferenced:
details.append(f"unreferenced catalog ids: {unreferenced}")
if missing:
details.append(f"missing catalog ids: {missing}")
raise TrainlogSemanticError(
f"session.exercises[{index}].sets[{set_index}]: "
f"actual mode {actual_mode!r} does not match target mode "
f"{target_mode!r}"
"catalog/reference set mismatch: " + "; ".join(details)
)
@ -171,7 +253,7 @@ def validate_document(
validator: jsonschema.Draft202012Validator,
path: Path,
) -> list[str]:
"""Return all validation errors for one Trainlog document."""
"""Return validation errors for one Trainlog document."""
try:
document = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
@ -202,6 +284,10 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
valid, invalid = discover_suite()
failed = False
if not valid:
print("FAIL test suite: no valid fixtures found")
return 1
if not invalid:
print("FAIL test suite: no invalid fixtures found")
return 1