chiptime 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. chiptime-0.4.0/.gitignore +35 -0
  2. chiptime-0.4.0/.python-version +1 -0
  3. chiptime-0.4.0/LICENSE +21 -0
  4. chiptime-0.4.0/PKG-INFO +60 -0
  5. chiptime-0.4.0/README.md +33 -0
  6. chiptime-0.4.0/pyproject.toml +73 -0
  7. chiptime-0.4.0/src/chiptime/__init__.py +43 -0
  8. chiptime-0.4.0/src/chiptime/__main__.py +3 -0
  9. chiptime-0.4.0/src/chiptime/_api.py +394 -0
  10. chiptime-0.4.0/src/chiptime/canonical.py +128 -0
  11. chiptime-0.4.0/src/chiptime/cli.py +361 -0
  12. chiptime-0.4.0/src/chiptime/decode.py +807 -0
  13. chiptime-0.4.0/src/chiptime/encode.py +306 -0
  14. chiptime-0.4.0/src/chiptime/errors.py +214 -0
  15. chiptime-0.4.0/src/chiptime/frames.py +524 -0
  16. chiptime-0.4.0/src/chiptime/intake.py +116 -0
  17. chiptime-0.4.0/src/chiptime/message.py +51 -0
  18. chiptime-0.4.0/src/chiptime/metrics/__init__.py +138 -0
  19. chiptime-0.4.0/src/chiptime/metrics/_basics.py +95 -0
  20. chiptime-0.4.0/src/chiptime/metrics/insights.py +374 -0
  21. chiptime-0.4.0/src/chiptime/metrics/intervals.py +564 -0
  22. chiptime-0.4.0/src/chiptime/metrics/load.py +254 -0
  23. chiptime-0.4.0/src/chiptime/metrics/pacing.py +224 -0
  24. chiptime-0.4.0/src/chiptime/metrics/settings.py +25 -0
  25. chiptime-0.4.0/src/chiptime/metrics/sports.py +108 -0
  26. chiptime-0.4.0/src/chiptime/metrics/zones.py +52 -0
  27. chiptime-0.4.0/src/chiptime/model.py +283 -0
  28. chiptime-0.4.0/src/chiptime/profile/__init__.py +61 -0
  29. chiptime-0.4.0/src/chiptime/profile/base_types.py +62 -0
  30. chiptime-0.4.0/src/chiptime/profile/core.py +454 -0
  31. chiptime-0.4.0/src/chiptime/profile/generated.py +6136 -0
  32. chiptime-0.4.0/src/chiptime/profile/registry.py +36 -0
  33. chiptime-0.4.0/src/chiptime/py.typed +0 -0
  34. chiptime-0.4.0/src/chiptime/repair.py +244 -0
  35. chiptime-0.4.0/src/chiptime/result.py +362 -0
  36. chiptime-0.4.0/src/chiptime/semantics/__init__.py +5 -0
  37. chiptime-0.4.0/src/chiptime/semantics/build.py +572 -0
  38. chiptime-0.4.0/src/chiptime/semantics/gaps.py +97 -0
  39. chiptime-0.4.0/src/chiptime/semantics/plausibility.py +126 -0
  40. chiptime-0.4.0/src/chiptime/semantics/reconcile.py +286 -0
  41. chiptime-0.4.0/src/chiptime/semantics/timers.py +125 -0
  42. chiptime-0.4.0/src/chiptime/validate.py +139 -0
  43. chiptime-0.4.0/tests/conformance/test_corpus.py +98 -0
  44. chiptime-0.4.0/tests/conftest.py +5 -0
  45. chiptime-0.4.0/tests/test_canonical.py +81 -0
  46. chiptime-0.4.0/tests/test_cli.py +89 -0
  47. chiptime-0.4.0/tests/test_decode.py +266 -0
  48. chiptime-0.4.0/tests/test_devfields.py +82 -0
  49. chiptime-0.4.0/tests/test_encode.py +117 -0
  50. chiptime-0.4.0/tests/test_hardening.py +88 -0
  51. chiptime-0.4.0/tests/test_insights_load.py +192 -0
  52. chiptime-0.4.0/tests/test_intake.py +83 -0
  53. chiptime-0.4.0/tests/test_intervals.py +187 -0
  54. chiptime-0.4.0/tests/test_metrics.py +90 -0
  55. chiptime-0.4.0/tests/test_mining_fixes.py +129 -0
  56. chiptime-0.4.0/tests/test_plausibility.py +59 -0
  57. chiptime-0.4.0/tests/test_reconcile.py +57 -0
  58. chiptime-0.4.0/tests/test_recovery.py +67 -0
  59. chiptime-0.4.0/tests/test_repair.py +103 -0
  60. chiptime-0.4.0/tests/test_semantics.py +74 -0
  61. chiptime-0.4.0/tests/test_sport_pacing.py +174 -0
  62. chiptime-0.4.0/tests/test_temporal.py +69 -0
  63. chiptime-0.4.0/tests/test_tier2_depth.py +153 -0
  64. chiptime-0.4.0/tests/test_validate.py +57 -0
  65. chiptime-0.4.0/tests/test_version.py +5 -0
  66. chiptime-0.4.0/uv.lock +1526 -0
@@ -0,0 +1,35 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ .venv/
8
+ venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ # Node (for the future JS implementation)
19
+ node_modules/
20
+ *.tsbuildinfo
21
+
22
+ # Editors
23
+ .idea/
24
+ .vscode/
25
+
26
+ # Claude workspace — local-only, never committed
27
+ .claude/
28
+ CLAUDE.md
29
+
30
+ # Real-device corpus tier: NEVER committed (ADR-0007 — PII)
31
+ corpus/private/
32
+ .site-build/
33
+
34
+ # Internal-only research (competitive analysis stays private)
35
+ docs/internal/
@@ -0,0 +1 @@
1
+ 3.13
chiptime-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxim Grigoryev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: chiptime
3
+ Version: 0.4.0
4
+ Summary: Recovery-grade FIT file processing: parse anything, lose nothing silently, explain everything.
5
+ Project-URL: Homepage, https://maxgrgrv.github.io/chiptime/
6
+ Project-URL: Documentation, https://maxgrgrv.github.io/chiptime/
7
+ Project-URL: Repository, https://github.com/MaxGrgrv/chiptime
8
+ Project-URL: Changelog, https://maxgrgrv.github.io/chiptime/changelog/
9
+ Project-URL: Issues, https://github.com/MaxGrgrv/chiptime/issues
10
+ Author: Maxim Grigoryev
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: activity,cycling,fit,fitness,garmin,parser,recovery,repair,running
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Provides-Extra: pandas
25
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # chiptime
29
+
30
+ **Recovery-grade FIT file processing.** Parse anything, lose nothing silently, explain everything. Zero runtime dependencies.
31
+
32
+ Every other open-source FIT parser stops at the first bad byte. chiptime resynchronizes mid-file, salvages truncated files, rebuilds missing session summaries, and can write the result back out as a valid, uploadable `.fit` — with a machine-readable provenance trail for every repair.
33
+
34
+ ```python
35
+ import chiptime
36
+
37
+ result = chiptime.parse("broken.fit") # lenient: recover + annotate
38
+ result.recovery # what was salvaged
39
+ result.activity.sessions[0].records.stream("power") # 0 is real, None is absent
40
+ result.to_canonical_json() # RFC 8785 — byte-identical every run
41
+
42
+ fixed = chiptime.repair("broken.fit") # → a valid .fit
43
+ ```
44
+
45
+ ```bash
46
+ chiptime parse ride.fit --json # agent exit codes: 0/2/3/4/64
47
+ chiptime repair broken.fit -o fixed.fit
48
+ chiptime validate fixed.fit --platform garmin-connect
49
+ chiptime inspect weird.fit # wire-level forensics
50
+ ```
51
+
52
+ - Three modes: `strict` / `lenient` / `forensic` (forensic never drops data)
53
+ - Sentinels → null before any statistics; zero ≠ null, always
54
+ - Deterministic canonical JSON; errors carry stable codes + suggested next steps
55
+ - Conformance corpus: 104-item edge-case taxonomy, golden-file tested
56
+ - Optional extras: `chiptime[pandas]` for DataFrames; `chiptime.metrics` for mean-max curves, zone time, SWOLF
57
+
58
+ Docs, taxonomy, corpus and research: https://github.com/MaxGrgrv/chiptime
59
+
60
+ chiptime is an independent project, not affiliated with or endorsed by Garmin. FIT and Garmin are trademarks of Garmin Ltd. This package ships no Garmin SDK files.
@@ -0,0 +1,33 @@
1
+ # chiptime
2
+
3
+ **Recovery-grade FIT file processing.** Parse anything, lose nothing silently, explain everything. Zero runtime dependencies.
4
+
5
+ Every other open-source FIT parser stops at the first bad byte. chiptime resynchronizes mid-file, salvages truncated files, rebuilds missing session summaries, and can write the result back out as a valid, uploadable `.fit` — with a machine-readable provenance trail for every repair.
6
+
7
+ ```python
8
+ import chiptime
9
+
10
+ result = chiptime.parse("broken.fit") # lenient: recover + annotate
11
+ result.recovery # what was salvaged
12
+ result.activity.sessions[0].records.stream("power") # 0 is real, None is absent
13
+ result.to_canonical_json() # RFC 8785 — byte-identical every run
14
+
15
+ fixed = chiptime.repair("broken.fit") # → a valid .fit
16
+ ```
17
+
18
+ ```bash
19
+ chiptime parse ride.fit --json # agent exit codes: 0/2/3/4/64
20
+ chiptime repair broken.fit -o fixed.fit
21
+ chiptime validate fixed.fit --platform garmin-connect
22
+ chiptime inspect weird.fit # wire-level forensics
23
+ ```
24
+
25
+ - Three modes: `strict` / `lenient` / `forensic` (forensic never drops data)
26
+ - Sentinels → null before any statistics; zero ≠ null, always
27
+ - Deterministic canonical JSON; errors carry stable codes + suggested next steps
28
+ - Conformance corpus: 104-item edge-case taxonomy, golden-file tested
29
+ - Optional extras: `chiptime[pandas]` for DataFrames; `chiptime.metrics` for mean-max curves, zone time, SWOLF
30
+
31
+ Docs, taxonomy, corpus and research: https://github.com/MaxGrgrv/chiptime
32
+
33
+ chiptime is an independent project, not affiliated with or endorsed by Garmin. FIT and Garmin are trademarks of Garmin Ltd. This package ships no Garmin SDK files.
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chiptime"
7
+ version = "0.4.0"
8
+ description = "Recovery-grade FIT file processing: parse anything, lose nothing silently, explain everything."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Maxim Grigoryev" }]
13
+ keywords = ["fit", "garmin", "fitness", "activity", "parser", "recovery", "repair", "cycling", "running"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Information Analysis",
23
+ "Typing :: Typed",
24
+ ]
25
+ # Contract: the core has ZERO runtime dependencies. Any addition here is a
26
+ # design decision requiring an ADR (see docs/dependencies/DEPENDENCY_MAP.md).
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://maxgrgrv.github.io/chiptime/"
31
+ Documentation = "https://maxgrgrv.github.io/chiptime/"
32
+ Repository = "https://github.com/MaxGrgrv/chiptime"
33
+ Changelog = "https://maxgrgrv.github.io/chiptime/changelog/"
34
+ Issues = "https://github.com/MaxGrgrv/chiptime/issues"
35
+
36
+ [project.optional-dependencies]
37
+ pandas = ["pandas>=2.0"]
38
+
39
+ [project.scripts]
40
+ chiptime = "chiptime.cli:main"
41
+
42
+ [dependency-groups]
43
+ docs = [
44
+ "mkdocs-material>=9.5",
45
+ "mkdocstrings[python]>=0.26",
46
+ "mkdocs-llmstxt>=0.2",
47
+ "mkdocs-git-revision-date-localized-plugin>=1.2",
48
+ ]
49
+ dev = [
50
+ "pytest>=8.3",
51
+ "hypothesis>=6.112",
52
+ "mypy>=1.11",
53
+ "ruff>=0.6.8",
54
+ ]
55
+ # Only for corpus/tools/scoreboard.py (M2) and profile cross-checks.
56
+ # Never installed by default; never imported by chiptime itself.
57
+ baselines = [
58
+ "fitparse>=1.2",
59
+ "fitdecode>=0.10",
60
+ ]
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/chiptime"]
64
+
65
+
66
+ [tool.mypy]
67
+ python_version = "3.11"
68
+ strict = true
69
+ files = ["src"]
70
+
71
+ [tool.pytest.ini_options]
72
+ testpaths = ["tests"]
73
+ addopts = "-q"
@@ -0,0 +1,43 @@
1
+ """chiptime — recovery-grade FIT file processing.
2
+
3
+ Parse anything, lose nothing silently, explain everything.
4
+
5
+ import chiptime
6
+ result = chiptime.parse("ride.fit") # lenient by default
7
+ result.ok, result.file_type, result.recovery
8
+ result.to_canonical_json() # deterministic (RFC 8785)
9
+ """
10
+
11
+ from chiptime._api import iter_frames, iter_messages, parse
12
+ from chiptime.errors import (
13
+ CrcMismatchError,
14
+ EmptyFileError,
15
+ FitError,
16
+ HeaderError,
17
+ NotFitError,
18
+ ProtocolError,
19
+ TruncatedError,
20
+ )
21
+ from chiptime.repair import NotRepairableError, RepairResult, repair
22
+ from chiptime.result import Mode, ParseResult
23
+
24
+ __version__ = "0.4.0"
25
+
26
+ __all__ = [
27
+ "CrcMismatchError",
28
+ "EmptyFileError",
29
+ "FitError",
30
+ "HeaderError",
31
+ "Mode",
32
+ "NotFitError",
33
+ "NotRepairableError",
34
+ "ParseResult",
35
+ "ProtocolError",
36
+ "RepairResult",
37
+ "TruncatedError",
38
+ "__version__",
39
+ "iter_frames",
40
+ "iter_messages",
41
+ "parse",
42
+ "repair",
43
+ ]
@@ -0,0 +1,3 @@
1
+ from chiptime.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,394 @@
1
+ """parse() — the one-call entry point; iter_frames/iter_messages streaming layers.
2
+
3
+ Mode policy per ADR-0003: strict raises the first defect; lenient recovers and
4
+ records; forensic is lenient that never drops (divergence begins in F5/F10).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import hashlib
11
+ from collections.abc import Iterator
12
+ from os import PathLike
13
+ from pathlib import Path
14
+ from typing import Any, BinaryIO
15
+
16
+ from chiptime.decode import Decoder
17
+ from chiptime.errors import (
18
+ Defect,
19
+ Diagnostic,
20
+ FitError,
21
+ ProvenanceEntry,
22
+ defect_to_error,
23
+ )
24
+ from chiptime.frames import (
25
+ CrcFrame,
26
+ DataFrame,
27
+ EndOfStream,
28
+ FileHeader,
29
+ FrameEvent,
30
+ SkippedBytes,
31
+ read_stream,
32
+ )
33
+ from chiptime.intake import unwrap
34
+ from chiptime.message import FieldValue, Message
35
+ from chiptime.result import FitPart, Mode, ParseResult, RecoveryReport, SourceInfo
36
+ from chiptime.semantics import build_activity
37
+
38
+ Source = str | PathLike[str] | bytes | bytearray | BinaryIO
39
+
40
+ # Structural defects that do NOT stop the stream — they surface as warnings
41
+ # in lenient/forensic ("seen and continued").
42
+ _CONTINUE_CODES = {
43
+ "FIT_HEADER_INVALID",
44
+ "FIT_HEADER_CRC_MISMATCH",
45
+ "FIT_CRC_MISMATCH",
46
+ "FIT_CRC_MISSING",
47
+ "FIT_DATA_SIZE_MISMATCH",
48
+ }
49
+
50
+ _SUGGESTIONS = {
51
+ "NOT_FIT_FORMAT": "route this file to a parser for the named format",
52
+ "FIT_TRUNCATED": 'rerun with mode="lenient" to salvage the decodable prefix',
53
+ "FIT_CRC_MISMATCH": 'rerun with mode="lenient" to decode despite the bad CRC',
54
+ "FIT_HEADER_CRC_MISMATCH": 'rerun with mode="lenient" to decode despite the bad header CRC',
55
+ "FIT_UNDEFINED_LOCAL_TYPE": 'rerun with mode="lenient" to salvage the decodable prefix',
56
+ "FIT_DEFINITION_INVALID": 'rerun with mode="lenient" to salvage the decodable prefix',
57
+ "FIT_DATA_SIZE_MISMATCH": 'rerun with mode="lenient" to parse the actual content',
58
+ }
59
+
60
+ _PII_MESSAGES = {"user_profile"}
61
+ _PII_FIELDS = {"serial_number"}
62
+
63
+
64
+ def _read_source(src: Source) -> tuple[bytes, str | None]:
65
+ if isinstance(src, (bytes, bytearray)):
66
+ return bytes(src), None
67
+ if isinstance(src, (str, PathLike)):
68
+ p = Path(src)
69
+ return p.read_bytes(), str(p)
70
+ data = src.read()
71
+ return bytes(data), getattr(src, "name", None)
72
+
73
+
74
+ def iter_frames(src: Source, *, mode: Mode = "lenient") -> Iterator[FrameEvent]:
75
+ """Lossless wire-level frame events (forensics layer)."""
76
+ data, _ = _read_source(src)
77
+ offset = 0
78
+ while offset < len(data):
79
+ consumed = offset
80
+ for ev in read_stream(data, offset=offset):
81
+ if isinstance(ev, Defect) and mode == "strict":
82
+ raise defect_to_error(ev, suggestion=_SUGGESTIONS.get(ev.code))
83
+ if isinstance(ev, EndOfStream):
84
+ consumed = ev.consumed
85
+ yield ev
86
+ if consumed <= offset or not _looks_like_header(data, consumed):
87
+ break
88
+ offset = consumed
89
+
90
+
91
+ def iter_messages(src: Source, *, mode: Mode = "lenient") -> Iterator[Message]:
92
+ """Profile-applied message stream without building the semantic model."""
93
+ decoder = Decoder()
94
+ for ev in iter_frames(src, mode=mode):
95
+ if isinstance(ev, DataFrame):
96
+ yield decoder.decode(ev)
97
+
98
+
99
+ def _looks_like_header(data: bytes, offset: int) -> bool:
100
+ if len(data) - offset < 12:
101
+ return False
102
+ return data[offset + 8 : offset + 12] == b".FIT" or data[offset] in (12, 14)
103
+
104
+
105
+ def parse(
106
+ src: Source,
107
+ *,
108
+ mode: Mode = "lenient",
109
+ strip_pii: bool = False,
110
+ include_unknown: bool = True,
111
+ include_raw: bool = False,
112
+ ) -> ParseResult:
113
+ """Parse a FIT source. lenient (default) recovers and annotates; strict
114
+ raises the first FitError; forensic maximizes salvage and never drops."""
115
+ raw, path = _read_source(src)
116
+ source_hash = hashlib.sha256(raw).hexdigest()
117
+ intake_result = unwrap(raw)
118
+ data = intake_result.data
119
+ source = SourceInfo(
120
+ path=path,
121
+ size_bytes=len(raw),
122
+ sha256=source_hash,
123
+ unwrapped=intake_result.unwrapped,
124
+ )
125
+
126
+ parts: list[FitPart] = []
127
+ provenance: list[ProvenanceEntry] = list(intake_result.provenance)
128
+ warnings: list[Diagnostic] = []
129
+ errors: list[FitError] = []
130
+
131
+ for d in intake_result.defects:
132
+ if mode == "strict":
133
+ raise defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code))
134
+ errors.append(defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code)))
135
+ if any(d.severity == "fatal" for d in intake_result.defects):
136
+ return ParseResult(
137
+ ok=False,
138
+ mode=mode,
139
+ source=source,
140
+ parts=[],
141
+ provenance=provenance,
142
+ warnings=warnings,
143
+ errors=errors,
144
+ recovery=None,
145
+ include_raw=include_raw,
146
+ )
147
+ total_recovered = 0
148
+ total_skipped = 0
149
+ resync_count = 0
150
+ recovery_engaged = False
151
+ est_total: int | None = None
152
+
153
+ offset = 0
154
+ part_index = 0
155
+ while True: # runs at least once so empty input still yields its defect
156
+ decoder = Decoder()
157
+ messages: list[Message] = []
158
+ stream_defects: list[Defect] = []
159
+ skips: list[SkippedBytes] = []
160
+ header: FileHeader | None = None
161
+ consumed = len(data)
162
+ body_bytes_decoded = 0
163
+
164
+ for ev in read_stream(data, offset=offset):
165
+ if isinstance(ev, Defect):
166
+ if mode == "strict":
167
+ raise defect_to_error(ev, suggestion=_SUGGESTIONS.get(ev.code))
168
+ stream_defects.append(ev)
169
+ elif isinstance(ev, SkippedBytes):
170
+ skips.append(ev)
171
+ elif isinstance(ev, DataFrame):
172
+ messages.append(decoder.decode(ev))
173
+ body_bytes_decoded = (
174
+ ev.offset
175
+ + 1
176
+ + len(ev.payload)
177
+ - (header.offset + header.size if header else offset)
178
+ )
179
+ elif isinstance(ev, FileHeader):
180
+ header = ev
181
+ elif isinstance(ev, CrcFrame):
182
+ pass # mismatch already surfaced as a Defect
183
+ elif isinstance(ev, EndOfStream):
184
+ consumed = ev.consumed
185
+
186
+ decode_out = decoder.finish()
187
+ messages = decode_out.messages # finish() may rebuild (late dev-field back-fill)
188
+ provenance.extend(decode_out.provenance)
189
+ warnings.extend(decode_out.diagnostics)
190
+ for d in decode_out.defects: # data-severity defects from decoding
191
+ if mode == "strict":
192
+ raise defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code))
193
+ warnings.append(Diagnostic(d.code, d.detail, f"byte {d.offset}"))
194
+
195
+ scope = f"part[{part_index}]"
196
+ skip_offsets = {s.offset for s in skips}
197
+ for skip in skips:
198
+ recovery_engaged = True
199
+ total_skipped += skip.length
200
+ if skip.reason == "preamble-garbage":
201
+ provenance.append(
202
+ ProvenanceEntry(
203
+ "PREAMBLE_GARBAGE_SKIPPED",
204
+ "repaired",
205
+ scope,
206
+ f"skipped {skip.length} garbage byte(s) before the FIT header",
207
+ byte_offset=skip.offset,
208
+ data={"length": skip.length},
209
+ )
210
+ )
211
+ else:
212
+ resync_count += 1
213
+ provenance.append(
214
+ ProvenanceEntry(
215
+ "RESYNC_SKIPPED_BYTES",
216
+ "repaired",
217
+ scope,
218
+ f"skipped {skip.length} undecodable byte(s) after {skip.reason}"
219
+ f" at offset {skip.offset}; decoding resumed",
220
+ byte_offset=skip.offset,
221
+ data={"length": skip.length, "defect_code": skip.reason},
222
+ )
223
+ )
224
+ for defect in stream_defects:
225
+ if defect.offset in skip_offsets and defect.severity == "structural":
226
+ continue # resynchronized: the SkippedBytes provenance tells the story
227
+ if defect.severity == "fatal":
228
+ errors.append(defect_to_error(defect, suggestion=_SUGGESTIONS.get(defect.code)))
229
+ elif defect.code in _CONTINUE_CODES:
230
+ warnings.append(Diagnostic(defect.code, defect.detail, f"byte {defect.offset}"))
231
+ else:
232
+ # Structural defect that stopped the stream: prefix salvage (F5 → resync).
233
+ recovery_engaged = True
234
+ code = (
235
+ "TRUNCATED_TAIL_SALVAGED"
236
+ if defect.code == "FIT_TRUNCATED"
237
+ else "STREAM_STOPPED_AT_DEFECT"
238
+ )
239
+ provenance.append(
240
+ ProvenanceEntry(
241
+ code,
242
+ "repaired",
243
+ scope,
244
+ f"{defect.detail}; salvaged {len(messages)} complete message(s)",
245
+ byte_offset=defect.offset,
246
+ data={"defect_code": defect.code},
247
+ )
248
+ )
249
+ if (
250
+ defect.code == "FIT_TRUNCATED"
251
+ and header is not None
252
+ and header.data_size
253
+ and body_bytes_decoded > 0
254
+ ):
255
+ est_total = round(len(messages) * header.data_size / body_bytes_decoded)
256
+
257
+ if messages or header is not None:
258
+ part = _build_part(messages)
259
+ if strip_pii:
260
+ _strip_pii(part, provenance, scope)
261
+ if not include_unknown:
262
+ _drop_unknown(part, provenance, scope)
263
+ if part.file_type == "activity":
264
+ part.activity = build_activity(
265
+ part.messages,
266
+ warnings,
267
+ provenance,
268
+ scope,
269
+ skipped_ranges=[(sk.offset, sk.offset + sk.length) for sk in skips],
270
+ forensic=(mode == "forensic"),
271
+ )
272
+ parts.append(part)
273
+ total_recovered += len(messages)
274
+
275
+ part_index += 1
276
+ if consumed <= offset:
277
+ break
278
+ offset = consumed
279
+ if offset >= len(data):
280
+ break
281
+ if not _looks_like_header(data, offset):
282
+ junk = Defect(
283
+ "FIT_TRAILING_JUNK",
284
+ f"{len(data) - offset} byte(s) after the final CRC are not a chained FIT file",
285
+ offset,
286
+ "structural",
287
+ )
288
+ if mode == "strict":
289
+ raise defect_to_error(junk)
290
+ if not any(
291
+ d.severity == "structural" and d.code not in _CONTINUE_CODES for d in stream_defects
292
+ ):
293
+ warnings.append(Diagnostic(junk.code, junk.detail, f"byte {offset}"))
294
+ break
295
+
296
+ ok = any(p.messages for p in parts) and not any(
297
+ e.code in ("FIT_EMPTY", "FIT_TOO_SMALL", "NOT_FIT_FORMAT") for e in errors
298
+ )
299
+ if not ok and not errors:
300
+ # Contract #5: ok=false must always be explained. The valid-but-empty
301
+ # shell (taxonomy #16, seen in the wild as 16-byte tool output).
302
+ errors.append(
303
+ FitError(
304
+ "FIT_NO_CONTENT",
305
+ "structurally valid FIT container with no messages — the data is"
306
+ " genuinely absent, not recoverable",
307
+ suggestion="nothing to salvage; check the device/app that wrote it",
308
+ )
309
+ )
310
+ recovery = (
311
+ RecoveryReport(
312
+ recovered_records=total_recovered,
313
+ estimated_total_records=est_total,
314
+ bytes_read=len(data),
315
+ bytes_skipped=total_skipped,
316
+ resync_count=resync_count,
317
+ )
318
+ if recovery_engaged
319
+ else None
320
+ )
321
+ return ParseResult(
322
+ ok=ok,
323
+ mode=mode,
324
+ source=source,
325
+ parts=parts,
326
+ provenance=provenance,
327
+ warnings=warnings,
328
+ errors=errors,
329
+ recovery=recovery,
330
+ include_raw=include_raw,
331
+ )
332
+
333
+
334
+ def _build_part(messages: list[Message]) -> FitPart:
335
+ file_id: dict[str, Any] | None = None
336
+ file_type = "unknown"
337
+ for m in messages:
338
+ if m.global_num == 0:
339
+ file_id = {k: fv.value for k, fv in m.fields.items()}
340
+ t = m.get("type")
341
+ if isinstance(t, str):
342
+ file_type = t
343
+ elif t is not None:
344
+ file_type = f"unknown_{t}"
345
+ break
346
+ return FitPart(file_type=file_type, file_id=file_id, messages=messages)
347
+
348
+
349
+ def _strip_pii(part: FitPart, provenance: list[ProvenanceEntry], scope: str) -> None:
350
+ removed_msgs = 0
351
+ nulled_fields = 0
352
+ kept: list[Message] = []
353
+ for m in part.messages:
354
+ if m.name in _PII_MESSAGES:
355
+ removed_msgs += 1
356
+ continue
357
+ if any(f in m.fields for f in _PII_FIELDS):
358
+ fields = dict(m.fields)
359
+ for f in _PII_FIELDS:
360
+ if f in fields:
361
+ fields[f] = FieldValue(None, None, fields[f].units)
362
+ nulled_fields += 1
363
+ m = dataclasses.replace(m, fields=fields)
364
+ kept.append(m)
365
+ part.messages = kept
366
+ if part.file_id and "serial_number" in part.file_id:
367
+ part.file_id["serial_number"] = None
368
+ if removed_msgs or nulled_fields:
369
+ provenance.append(
370
+ ProvenanceEntry(
371
+ "PII_STRIPPED",
372
+ "dropped",
373
+ scope,
374
+ f"removed {removed_msgs} PII message(s), nulled {nulled_fields}"
375
+ f" serial-number field(s) (strip_pii=True)",
376
+ data={"messages_removed": removed_msgs, "fields_nulled": nulled_fields},
377
+ )
378
+ )
379
+
380
+
381
+ def _drop_unknown(part: FitPart, provenance: list[ProvenanceEntry], scope: str) -> None:
382
+ known = [m for m in part.messages if not m.name.startswith("unknown_")]
383
+ dropped = len(part.messages) - len(known)
384
+ part.messages = known
385
+ if dropped:
386
+ provenance.append(
387
+ ProvenanceEntry(
388
+ "UNKNOWN_MESSAGES_OMITTED",
389
+ "ignored",
390
+ scope,
391
+ f"{dropped} unknown message(s) omitted from output (include_unknown=False)",
392
+ data={"count": dropped},
393
+ )
394
+ )