diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d781b..a31226c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,17 +15,22 @@ The project uses a simple pre-release changelog during early development. - Positive and negative exchange-format fixtures. - Gate 0 review and closure records. - Gate 1 exchange-format freeze-candidate review. +- Gate 1 exercise-identity collision 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. +- Executable local catalog reconciliation contract. +- UUIDv4 generation policy for new Trainlog exercise and session IDs. ### Changed - 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. - 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. -- `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. diff --git a/docs/android.md b/docs/android.md index 947f1f2..8eba8e5 100644 --- a/docs/android.md +++ b/docs/android.md @@ -178,3 +178,30 @@ Initial Android versions do not need: - muscle classification; - distance/cardio metrics; - per-set rest measurement. +## 13. Identifier generation + +When Android creates a new exercise, it generates: + +```text +ex_ +``` + +When Android creates a new session, it generates: + +```text +se_ +``` + +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. diff --git a/docs/exchange_format.md b/docs/exchange_format.md index 16916d6..5142458 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -480,3 +480,89 @@ Until then: ```text 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_ +``` + +Generated session identifiers use: + +```text +se_ +``` + +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 +``` diff --git a/docs/reviews/gate1_review_02.md b/docs/reviews/gate1_review_02.md new file mode 100644 index 0000000..c9afb2f --- /dev/null +++ b/docs/reviews/gate1_review_02.md @@ -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 +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 4074d69..9241f39 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,16 +11,6 @@ GATE_0=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: ```text @@ -35,43 +25,46 @@ ebd4316ed68c58598a471e567edf13455d00f92b ## Gate 1 — Exchange format v1 freeze -Status: VALIDATION PENDING — REVIEW #1 +Status: VALIDATION PENDING — REVIEW #2 Canonical state: ```text GATE_1_REVIEW_01=IMPLEMENTED +GATE_1_REVIEW_02=IMPLEMENTED GATE_1=VALIDATION_PENDING TRAINLOG_FORMAT_V1=DRAFT ``` -Review #1 freezes the proposed model for: +Review #1 defined: -- 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; -- final v1 body-measurement field list; -- optional notes; -- strict catalog completeness; -- strict unknown-field behavior. +- exercise identity and tracking mode; +- session identity and ordering; +- load semantics; +- target versus actual work; +- rest; +- body weight and measurements; +- notes; +- strict document validation. + +Review #2 closes: + +- official UUIDv4 identifier generation; +- Android/TUI catalog collision handling; +- hard tracking-mode identity conflicts; +- different-ID/same-name anti-duplicate conflicts; +- atomic catalog reconciliation. Exit criteria: -- all valid fixtures accepted; -- all invalid fixtures rejected for the intended reason; +- `python tools/validate_json.py` passes; +- `python tools/validate_import_contract.py` passes; +- `git diff --check` passes; - Android documentation aligned; - TUI documentation aligned; -- schema and semantic validator aligned; -- review commit pushed to Forgejo and GitHub; +- both review commits pushed to Forgejo and GitHub; - mirrored review passes; -- `TRAINLOG_FORMAT_V1=FROZEN`. +- closure sets `TRAINLOG_FORMAT_V1=FROZEN`. ## Gate 2 — TUI persistence core @@ -80,7 +73,9 @@ Deliverables: - Meson C17 project; - SQLite open/create; - schema versioning; +- UUIDv4 identity generation; - exercise catalog; +- atomic catalog reconciliation; - session import transaction; - idempotent import tests. @@ -113,6 +108,7 @@ Exit criteria: Deliverables: - local exercise catalog; +- UUIDv4 identity generation; - start/stop session timestamps; - target entry; - actual-set entry; diff --git a/docs/tests.md b/docs/tests.md index daf4297..fd27ea6 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -111,3 +111,28 @@ Before every meaningful push: 4. run `git diff --check`; 5. inspect `git status --short`; 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 +``` diff --git a/docs/tui.md b/docs/tui.md index b071bcc..d3769c5 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -144,3 +144,47 @@ Numeric input is validated before persistent state is committed. Invalid input must never partially mutate a saved session. 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_ +``` + +When the TUI creates a new session directly, it generates: + +```text +se_ +``` + +The database stores these identifiers as opaque stable text. diff --git a/tests/contract/catalog-import-cases.json b/tests/contract/catalog-import-cases.json new file mode 100644 index 0000000..67d8051 --- /dev/null +++ b/tests/contract/catalog-import-cases.json @@ -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" + } +] diff --git a/tools/validate_import_contract.py b/tools/validate_import_contract.py new file mode 100755 index 0000000..249c715 --- /dev/null +++ b/tools/validate_import_contract.py @@ -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())