Define Trainlog v1 catalog identity rules

This commit is contained in:
fy59 2026-09-05 19:25:54 +02:00
parent 9d9a9223a0
commit dfd6717cb7
9 changed files with 539 additions and 34 deletions

View file

@ -15,17 +15,22 @@ The project uses a simple pre-release changelog during early development.
- Positive and negative exchange-format fixtures. - Positive and negative exchange-format fixtures.
- Gate 0 review and closure records. - Gate 0 review and closure records.
- Gate 1 exchange-format freeze-candidate review. - Gate 1 exchange-format freeze-candidate review.
- Gate 1 exercise-identity collision review.
- Stable exercise tracking modes. - Stable exercise tracking modes.
- Explicit load modes for no load, external resistance, and assistance. - Explicit load modes for no load, external resistance, and assistance.
- Optional bounded session and exercise notes. - Optional bounded session and exercise notes.
- Extended body measurement list. - Extended body measurement list.
- Executable local catalog reconciliation contract.
- UUIDv4 generation policy for new Trainlog exercise and session IDs.
### Changed ### Changed
- Gate 0 project contract is complete. - Gate 0 project contract is complete.
- Gate 1 is the active format-freeze gate. - Gate 1 remains active pending review #2 validation.
- Actual repetition count may be zero for a failed attempt. - Actual repetition count may be zero for a failed attempt.
- Planned exercises may contain zero actual sets. - Planned exercises may contain zero actual sets.
- Top-level exercise metadata must exactly match the session exercise references. - Top-level exercise metadata must exactly match session exercise references.
- Assistance kilograms have distinct semantics from external resistance. - Assistance kilograms have distinct semantics from external resistance.
- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 validation and mirrored review. - Different exercise IDs with equivalent normalized names are hard import conflicts.
- Same exercise ID with incompatible tracking mode is a hard import conflict.
- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 closure.

View file

@ -178,3 +178,30 @@ Initial Android versions do not need:
- muscle classification; - muscle classification;
- distance/cardio metrics; - distance/cardio metrics;
- per-set rest measurement. - per-set rest measurement.
## 13. Identifier generation
When Android creates a new exercise, it generates:
```text
ex_<random UUID v4>
```
When Android creates a new session, it generates:
```text
se_<random UUID v4>
```
Display-name slugs must not be used as persistent identifiers.
The visible exercise name remains independent from identity.
## 14. Catalog conflict behavior
Android must prevent duplicate normalized names inside its own local catalog.
A valid Android export can still conflict with an independently edited TUI catalog.
The TUI owns final reconciliation.
Android must not assume that a matching display name means two different IDs may be silently merged.

View file

@ -480,3 +480,89 @@ Until then:
```text ```text
TRAINLOG_FORMAT_V1=DRAFT TRAINLOG_FORMAT_V1=DRAFT
``` ```
## 23. Generated identifier policy
The wire format treats `exercise_id` and `session_id` as opaque identifiers satisfying their defined syntax.
Official Trainlog implementations that create new identifiers must generate random UUID version 4 values.
Generated exercise identifiers use:
```text
ex_<uuid-v4>
```
Generated session identifiers use:
```text
se_<uuid-v4>
```
Example:
```text
ex_550e8400-e29b-41d4-a716-446655440000
se_550e8400-e29b-41d4-a716-446655440000
```
Importers accept any identifier valid under the v1 schema; they must not require that legacy or externally created IDs follow the UUID generation convention.
The generation rule exists to prevent collisions when Android and TUI can both create exercises independently.
## 24. Local catalog reconciliation
Document validity and local importability are separate concepts.
For each incoming exercise, the TUI reconciles against the local canonical catalog before the import transaction commits.
### Same ID, same tracking mode
Reuse the existing exercise.
If the normalized display name differs, import is allowed but a non-fatal metadata warning is surfaced.
The canonical local name is not silently changed.
### Same ID, different tracking mode
Reject the entire session import.
A repetition identity and duration identity are semantically incompatible.
### Different ID, equivalent normalized name
Reject the entire session import as an identity conflict.
Trainlog must not silently:
- create a duplicate;
- merge identities;
- rewrite historical identifiers.
The user must explicitly reconcile the conflict.
### New ID, unique normalized name
Create the exercise as part of the same database transaction as the session import.
### Atomicity
Any hard catalog conflict aborts the entire import.
No partial session or exercise data may remain.
## 25. Gate 1 executable import contract
Before the production importer exists, catalog reconciliation is specified by:
```text
tests/contract/catalog-import-cases.json
tools/validate_import_contract.py
```
Canonical validation requires both:
```bash
python tools/validate_json.py
python tools/validate_import_contract.py
```

View file

@ -0,0 +1,165 @@
# Gate 1 Review #2 — Exercise Identity Collision Rules
## Status
```text
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1_REVIEW_02=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
## Purpose
Review #1 defined the shape of Trainlog v1.
Review #2 closes the remaining identity ambiguity between the Android exercise catalog and the canonical TUI catalog.
This review is intentionally narrow.
## G1-R2-01 — Generated identifiers must be globally collision-resistant
The serialized format continues to treat `exercise_id` and `session_id` as opaque identifiers satisfying their documented syntax.
Trainlog implementations that create new identifiers must use random UUID version 4 values.
Recommended generated exercise identifier:
```text
ex_550e8400-e29b-41d4-a716-446655440000
```
Recommended generated session identifier:
```text
se_550e8400-e29b-41d4-a716-446655440000
```
The prefixes are part of the generated identifier.
Importers must not require the UUID generation pattern for already-existing conforming v1 documents.
This separates two concerns:
- the wire format accepts the documented opaque identifier syntax;
- official Trainlog implementations generate collision-resistant identifiers.
## G1-R2-02 — Existing ID and compatible metadata
If an incoming exercise has the same `exercise_id` and the same `tracking_mode` as an existing local exercise, the existing identity is reused.
If the normalized display name is also equal:
```text
action = reuse
```
No warning is needed.
## G1-R2-03 — Existing ID with a different display name
If `exercise_id` and `tracking_mode` match but the display name differs:
```text
action = reuse_with_name_warning
```
The session may import.
The local canonical exercise is not silently renamed.
The UI must surface a non-fatal metadata warning.
Deliberate rename/synchronization is a separate user action.
## G1-R2-04 — Existing ID with incompatible tracking mode
If the same `exercise_id` arrives with a different `tracking_mode`:
```text
action = reject_mode_conflict
```
The session import must not continue.
The conflict cannot be repaired by silently changing history because `reps` and `duration` have different semantics.
## G1-R2-05 — Different IDs with equivalent normalized names
If an incoming exercise has a different `exercise_id` but its normalized name equals an existing local exercise name:
```text
action = reject_name_identity_conflict
```
The importer must not:
- create a duplicate exercise;
- silently merge the two identities;
- silently rewrite the incoming session to the existing ID.
The user must explicitly reconcile the identity conflict.
This is the critical anti-duplicate rule for independently edited Android and TUI catalogs.
## G1-R2-06 — New ID and unique normalized name
If neither the identifier nor normalized name conflicts with the local catalog:
```text
action = create
```
The new exercise is inserted during the same import transaction as the session.
## G1-R2-07 — Reconciliation is atomic
Any hard catalog conflict rejects the entire session import transaction.
The TUI must not partially:
- create some new exercises;
- insert the session;
- insert sets.
The database remains unchanged after a rejected import.
## G1-R2-08 — Document validity versus local importability
A Trainlog JSON document can be structurally and semantically valid on its own yet still be non-importable into a particular local database because of a catalog identity conflict.
Therefore v1 has two distinct checks:
```text
document validation
local catalog reconciliation
```
Gate 1 freezes both contracts.
## Executable contract
Canonical reconciliation cases live in:
```text
tests/contract/catalog-import-cases.json
```
Validate them with:
```bash
python tools/validate_import_contract.py
```
This validator is an executable specification used before the production C17 importer exists.
## Freeze decision
After both Gate 1 reviews are validated, committed, pushed, and mirrored, the closure step may set:
```text
GATE_1_REVIEW_01=PASS
GATE_1_REVIEW_02=PASS
GATE_1=PASS
TRAINLOG_FORMAT_V1=FROZEN
```

View file

@ -11,16 +11,6 @@ GATE_0=PASS
GATE_0_REVIEW_01=PASS GATE_0_REVIEW_01=PASS
``` ```
Gate 0 established:
- repository structure;
- development contract;
- architecture documentation;
- coding-style rules;
- exchange-format draft;
- structural and semantic validation;
- positive and negative fixture strategy.
Reviewed hardening commit: Reviewed hardening commit:
```text ```text
@ -35,43 +25,46 @@ ebd4316ed68c58598a471e567edf13455d00f92b
## Gate 1 — Exchange format v1 freeze ## Gate 1 — Exchange format v1 freeze
Status: VALIDATION PENDING — REVIEW #1 Status: VALIDATION PENDING — REVIEW #2
Canonical state: Canonical state:
```text ```text
GATE_1_REVIEW_01=IMPLEMENTED GATE_1_REVIEW_01=IMPLEMENTED
GATE_1_REVIEW_02=IMPLEMENTED
GATE_1=VALIDATION_PENDING GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT TRAINLOG_FORMAT_V1=DRAFT
``` ```
Review #1 freezes the proposed model for: Review #1 defined:
- exercise identity; - exercise identity and tracking mode;
- stable repetition/duration tracking mode; - session identity and ordering;
- session identity; - load semantics;
- session ordering; - target versus actual work;
- external/none/assistance load semantics; - rest;
- planned versus actual sets; - body weight and measurements;
- zero-repetition failed attempts; - notes;
- planned exercises with zero actual sets; - strict document validation.
- planned rest;
- body weight; Review #2 closes:
- final v1 body-measurement field list;
- optional notes; - official UUIDv4 identifier generation;
- strict catalog completeness; - Android/TUI catalog collision handling;
- strict unknown-field behavior. - hard tracking-mode identity conflicts;
- different-ID/same-name anti-duplicate conflicts;
- atomic catalog reconciliation.
Exit criteria: Exit criteria:
- all valid fixtures accepted; - `python tools/validate_json.py` passes;
- all invalid fixtures rejected for the intended reason; - `python tools/validate_import_contract.py` passes;
- `git diff --check` passes;
- Android documentation aligned; - Android documentation aligned;
- TUI documentation aligned; - TUI documentation aligned;
- schema and semantic validator aligned; - both review commits pushed to Forgejo and GitHub;
- review commit pushed to Forgejo and GitHub;
- mirrored review passes; - mirrored review passes;
- `TRAINLOG_FORMAT_V1=FROZEN`. - closure sets `TRAINLOG_FORMAT_V1=FROZEN`.
## Gate 2 — TUI persistence core ## Gate 2 — TUI persistence core
@ -80,7 +73,9 @@ Deliverables:
- Meson C17 project; - Meson C17 project;
- SQLite open/create; - SQLite open/create;
- schema versioning; - schema versioning;
- UUIDv4 identity generation;
- exercise catalog; - exercise catalog;
- atomic catalog reconciliation;
- session import transaction; - session import transaction;
- idempotent import tests. - idempotent import tests.
@ -113,6 +108,7 @@ Exit criteria:
Deliverables: Deliverables:
- local exercise catalog; - local exercise catalog;
- UUIDv4 identity generation;
- start/stop session timestamps; - start/stop session timestamps;
- target entry; - target entry;
- actual-set entry; - actual-set entry;

View file

@ -111,3 +111,28 @@ Before every meaningful push:
4. run `git diff --check`; 4. run `git diff --check`;
5. inspect `git status --short`; 5. inspect `git status --short`;
6. review documentation changes. 6. review documentation changes.
## 10. Catalog reconciliation contract
Before the C17 importer exists, Gate 1 defines local catalog merge behavior through an executable Python specification.
Run:
```bash
python tools/validate_import_contract.py
```
Canonical cases cover:
- exact existing exercise reuse;
- same identity with renamed display text;
- same identity with incompatible tracking mode;
- different identities with equivalent normalized names;
- new unique exercise creation.
The full Gate 1 validation command is:
```bash
python tools/validate_json.py
python tools/validate_import_contract.py
git diff --check
```

View file

@ -144,3 +144,47 @@ Numeric input is validated before persistent state is committed.
Invalid input must never partially mutate a saved session. Invalid input must never partially mutate a saved session.
Imports use full validation before the database transaction commits. Imports use full validation before the database transaction commits.
## 14. Catalog reconciliation during import
Before creating any exercise or session rows, the TUI classifies incoming exercise metadata against the canonical local catalog.
Rules:
```text
same ID + same mode + same normalized name
-> reuse
same ID + same mode + different name
-> reuse + metadata warning
same ID + different mode
-> reject entire import
different ID + same normalized name
-> reject entire import
new ID + unique normalized name
-> create inside import transaction
```
The TUI must never silently merge different exercise IDs merely because names match.
The TUI must never create two identities with equivalent normalized display names.
Any hard catalog conflict aborts the complete session import transaction.
## 15. Generated identifiers
When the TUI creates a new exercise directly, it generates:
```text
ex_<random UUID v4>
```
When the TUI creates a new session directly, it generates:
```text
se_<random UUID v4>
```
The database stores these identifiers as opaque stable text.

View file

@ -0,0 +1,86 @@
[
{
"name": "same-id-same-name-same-mode",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
"expected": "reuse"
},
{
"name": "same-id-renamed-same-mode",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse jambes",
"tracking_mode": "reps"
},
"expected": "reuse_with_name_warning"
},
{
"name": "same-id-mode-conflict",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Gainage",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Gainage",
"tracking_mode": "duration"
},
"expected": "reject_mode_conflict"
},
{
"name": "different-id-equivalent-name",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
"name": " PRESSE À CUISSES ",
"tracking_mode": "reps"
},
"expected": "reject_name_identity_conflict"
},
{
"name": "different-id-equivalent-name-different-mode",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Gainage",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
"name": " gainage ",
"tracking_mode": "duration"
},
"expected": "reject_name_identity_conflict"
},
{
"name": "new-id-unique-name",
"local": {
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
"incoming": {
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
"name": "Gainage ventral",
"tracking_mode": "duration"
},
"expected": "create"
}
]

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Validate Trainlog v1 catalog reconciliation contract cases."""
from __future__ import annotations
import json
import sys
import unicodedata
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CASES_PATH = ROOT / "tests" / "contract" / "catalog-import-cases.json"
def normalize_exercise_name(name: str) -> str:
"""Return the v1 normalized comparison form for an exercise name."""
nfc = unicodedata.normalize("NFC", name)
collapsed = " ".join(nfc.strip().split())
return collapsed.casefold()
def classify(local: dict[str, Any], incoming: dict[str, Any]) -> str:
"""Classify one incoming catalog entry against one existing local entry.
This function is executable specification for the Gate 1 import contract.
It does not mutate either object.
"""
same_id = local["exercise_id"] == incoming["exercise_id"]
same_name = normalize_exercise_name(local["name"]) == normalize_exercise_name(
incoming["name"]
)
same_mode = local["tracking_mode"] == incoming["tracking_mode"]
if same_id:
if not same_mode:
return "reject_mode_conflict"
if same_name:
return "reuse"
return "reuse_with_name_warning"
if same_name:
return "reject_name_identity_conflict"
return "create"
def main() -> int:
"""Run every canonical catalog reconciliation case."""
with CASES_PATH.open("r", encoding="utf-8") as handle:
cases = json.load(handle)
failed = False
for case in cases:
actual = classify(case["local"], case["incoming"])
expected = case["expected"]
if actual != expected:
print(
f"FAIL {case['name']}: expected {expected!r}, got {actual!r}"
)
failed = True
else:
print(f"PASS {case['name']}: {actual}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())