diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d8541c8..e970c8f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -30,6 +30,8 @@ jobs:
- name: Build source and wheel distributions
run: python -m build
- name: Install wheel instead of editable source
- run: python -m pip install --force-reinstall --no-deps dist/uart_fixture_kit-0.1.0-py3-none-any.whl
+ run: python -m pip install --force-reinstall --no-deps dist/uart_fixture_kit-0.2.0-py3-none-any.whl
- name: Replay installed fixtures
run: python -m uart_fixture_kit demo --json
+ - name: Profile installed parsers with a small synthetic corpus
+ run: python -m uart_fixture_kit profile --records 8 --repeats 3 --warmups 1 --output profile-smoke.json --html profile-smoke.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd0ef75..81145ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.2.0
+
+- Chunk-scanning JSON alternative, preserving the byte-wise reference parser.
+- Correctness-gated parser profiles with fixed synthetic workloads, raw paired timing samples, source/fixture hashes and cProfile hotspots.
+- Standalone HTML profile reports and installed-wheel CLI verification in CI.
+
## 0.1.0
- Protocol-independent JSON byte fixtures and virtual-time replay.
diff --git a/MANIFEST.in b/MANIFEST.in
index 435ac67..097e40b 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,2 @@
include CONTRIBUTING.md CHANGELOG.md
-recursive-include docs *.md
+recursive-include docs *.md *.json *.html *.png
diff --git a/README.md b/README.md
index bd025bb..f30ff05 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ A serial read can return half a message, several messages, or noise followed by
Python 3.10 or newer. No runtime dependencies, API keys, GPU, or hardware.
```console
-python -m pip install "git+https://github.com/vanovri/uart-fixture-kit.git@v0.1.0"
+python -m pip install "git+https://github.com/vanovri/uart-fixture-kit.git@v0.2.0"
uart-fixture-kit demo
uart-fixture-kit demo --json
```
@@ -20,6 +20,29 @@ The demo runs bundled synthetic cases for newline JSON and a small binary protoc
GitHub is the distribution source for this release. This package has not been published on PyPI.
+## UART Parse Profiler
+
+Version 0.2 adds a chunk-scanning JSON alternative without changing the byte-wise reference parser or the default demo. Measure both against the same synthetic input and virtual timestamps:
+
+```console
+uart-fixture-kit profile --records 2048 --repeats 9 --warmups 3 --output uart-profile.json --html uart-profile.html
+```
+
+Open the HTML file for the timing table and cProfile hotspots. JSON keeps every timing sample, environment details, source hashes and a manifest covering input bytes, timestamps and expected records. Seven saved JSON fixtures and four generated workloads must match their explicit record oracles on both parsers before timing. Each timed run is checked again outside the timer. No network connection or serial hardware is required.
+
+One Windows/CPython 3.14.7 run on 18 October 2026 measured the following medians over nine paired repeats:
+
+| Synthetic workload | Reference ms | Chunk scanning ms | Reference / candidate |
+| --- | ---: | ---: | ---: |
+| 76,739 bytes in 4,096-byte chunks | 18.368 | 8.669 | 2.12x |
+| The same stream, repeating chunks 1/2/7/3 | 27.723 | 26.099 | 1.06x |
+| The same stream, one byte per event | 44.280 | 56.507 | 0.78x |
+| Malformed, oversized, stale and partial input recovery | 0.064 | 0.046 | 1.38x |
+
+The alternative was **slower for byte-by-byte arrival**. The smaller difference on fragmented input may be sensitive to measurement noise. These are parser/event-dispatch measurements on one machine, not UART link speed or a general performance guarantee. The original parser remains the default. See [methodology and exact replay](docs/PROFILING.md), [recorded JSON](docs/profile-sample.json) and [standalone HTML](docs/profile-sample.html).
+
+
+
## Catch a real class of bug
This fixture delivers `OK\n` in two reads:
@@ -150,6 +173,8 @@ python -m build
Tests include a failing broken-parser regression, every two-chunk split of representative streams, byte-by-byte corruption recovery, timer boundary cases, CLI exit codes, fixture validation, and installed-package demo checks. CI runs on Windows and Linux across Python 3.10, 3.12, and 3.14.
+Profiler tests also cover the 127/128/129-byte line boundary, mixed invalid input, empty-feed timeout behavior, bounded oversized partial input, rejection of an incorrect candidate before timing, raw-sample reports and the installed-wheel CLI.
+
## Related tools
[pySerial](https://pyserial.readthedocs.io/en/latest/) provides serial transport and loopback facilities. [Serial Studio](https://github.com/Serial-Studio/Serial-Studio) provides telemetry visualization and capture/replay. [WireDAQ](https://github.com/wuisabel-gif/WireDAQ) includes its own wire protocol and fault simulation. This project focuses on a small fixture contract for adapters to existing parsers. Replay and fault injection are established techniques.
diff --git a/docs/PROFILING.md b/docs/PROFILING.md
new file mode 100644
index 0000000..5262d75
--- /dev/null
+++ b/docs/PROFILING.md
@@ -0,0 +1,42 @@
+# UART Parse Profiler
+
+This extends the existing synthetic parser fixture kit. The reference `JsonLineParser` remains unchanged. `ChunkedJsonLineParser` scans for newlines using `bytes.find` and appends bounded slices instead of looping over every byte in Python. Both retain unfinished bytes, reject lines longer than 128 bytes, recover after newline or inactivity, validate fields and emit the same diagnostic records. The alternative inherits the reference's `advance` and `finish` methods.
+
+## Replay the comparison
+
+```console
+python -m uart_fixture_kit profile --records 2048 --repeats 9 --warmups 3 --output uart-profile.json --html uart-profile.html
+```
+
+This runs two parsers over seven bundled JSON fixtures and four generated workloads, giving 22 correctness comparisons. A mismatch prevents timing entirely. Subsequent warm-ups, measured runs and cProfile runs must also preserve canonical JSON output exactly. Boolean and numeric validation, diagnostic order and unfinished-buffer behavior belong to the contract, not just decoded values.
+
+The three valid-data workloads use the same 2,048 records and 76,739 bytes. Only chunk boundaries and the resulting virtual event schedule differ: chunks of up to 4,096 bytes, a repeating 1/2/7/3 schedule, and individual bytes. The fourth workload has an explicit oracle for an expired partial record, invalid UTF-8, an oversized line, a bad unit, a valid message, and an EOF fragment. Workload creation is deterministic and uses no random state. Additional seeded differential tests explore mixed-stream chunk boundaries, alongside independent saved oracles and line-boundary cases.
+
+`fixture_manifest` and each workload hash cover canonical JSON containing the fixture name, exact event bytes, timestamps, end time and expected records. The report hashes both parser class definitions. Because the alternative inherits timer/EOF methods, keep the reference source hash with the alternative's hash. Reports record Python, OS, CPU information, repeat count and warm-up count. Source and fixture hashes identify what ran, not physical-device validity.
+
+## What the timer measures
+
+The `perf_counter_ns` interval includes constructing a fresh parser, dispatching virtual `advance`/`feed`/`finish` calls and collecting emitted records. It excludes fixture generation, JSON equivalence checks, hashing, report creation and cProfile. Virtual timestamps are inputs to parser logic, not wall-clock sleeps.
+
+Both parsers receive warm-ups before measurement. Each repeat is paired. The reference runs first on even-numbered iterations and second on odd-numbered iterations to reduce a fixed ordering bias. Garbage collection is disabled only during each timed run and restored to its previous state afterward. Raw nanosecond samples are retained, with median, minimum, maximum, population standard deviation and nearest-rank observed p95. With nine samples, p95 is the maximum observed sample, not a latency-distribution estimate.
+
+`speed_ratio` is reference median divided by alternative median. Above 1 means the alternative took less median time. `candidate_time_change_percent` is (alternative/reference - 1) times 100, so a positive number means a slowdown. These ratios use measured parser times, not a device's baud rate. Small recovery timings and small differences are especially sensitive to noise.
+
+A separate cProfile run uses the bulk workload. Its cumulative timings overlap and include instrumentation overhead. Do not add cumulative times or compare them directly with uninstrumented wall times. It helps locate Python-level work; it does not prove the entire application sped up.
+
+## Observed example
+
+The checked-in `profile-sample.json` and HTML came from one run on 18 October 2026 WIB, CPython 3.14.7, Windows 11, Intel64 Family 6 Model 165, with eight logical CPUs. With nine measured repeats and three warm-ups:
+
+- Bulk chunk median: 18.368 ms reference, 8.669 ms alternative, ratio 2.12x.
+- Fragmented median: 27.723 ms reference, 26.099 ms alternative, ratio 1.06x.
+- Byte-wise median: 44.280 ms reference, 56.507 ms alternative, ratio 0.78x.
+- Fault-recovery median: 0.064 ms reference, 0.046 ms alternative, ratio 1.38x.
+
+The alternative was slower on one-byte events. It remains opt-in. Larger chunks allow the C-level search to avoid much of the Python byte loop, while tiny chunks still pay search and method-call overhead. On the bulk cProfile run, the reference made 74,691 `bytearray.append` calls. That observation supports the mechanism being explored; it does not establish the same ratio on another machine.
+
+There is no CPU-affinity setting, process isolation, thermal control or hardware I/O. Other applications may affect the results. The HTML and JSON deliberately preserve the slower case. Re-run on your target workload before choosing a parser. This synthetic comparison does not validate electrical UART behavior, sensor timing, production reliability or clinical use.
+
+## Provenance
+
+The source, workload definitions and record oracles are original synthetic additions for this project, developed with Codex assistance. They contain no shared thesis code, private captures or subject data. The broad theme of profiling before accepting an optimization was inspired by [Google's account of optimizing the ADK Python codebase](https://developers.googleblog.com/optimizing-a-200000-line-python-codebase-overnight-with-gemini-4-argon/). This project does not run or benchmark Gemini, and none of Google's performance figures are reused as local results.
diff --git a/docs/profile-preview.png b/docs/profile-preview.png
new file mode 100644
index 0000000..e877bc1
Binary files /dev/null and b/docs/profile-preview.png differ
diff --git a/docs/profile-sample.html b/docs/profile-sample.html
new file mode 100644
index 0000000..eb6cfd5
--- /dev/null
+++ b/docs/profile-sample.html
@@ -0,0 +1,26 @@
+
+
UART Parse Profileruart-fixture-kit / Parse Profiler2026-10-18T00:14:59.200252+00:00
+Parser output first.
Timing second.
Synthetic byte streams. Virtual arrival times. Actual local measurements.
+The unchanged byte-wise JSON reference parser is compared with a chunk-scanning alternative. Every timed run must preserve the expected records, including diagnostics.
+22 / 22 correctness checks passedBundled JSON fixtures and four generated workloads run through both parsers before measurement.
+Paired timing results
Median milliseconds. The smaller line shows min–max over 9 measured runs after 3 warm-ups. Ratio = reference median / chunk-scanning median. Values below 1 mean the alternative was slower.
+| Workload | Reference ms | Chunk scanning ms | Ratio |
|---|
| bulk-409676,739 bytes / 19 events |
+18.36817.822–20.138 |
+8.6698.493–9.218 |
+2.12x |
|---|
| fragmented-1-2-7-376,739 bytes / 23,612 events |
+27.72326.933–36.215 |
+26.09924.787–28.874 |
+1.06x |
|---|
| bytewise76,739 bytes / 76,739 events |
+44.28042.690–46.388 |
+56.50754.889–59.571 |
+0.78x |
|---|
| fault-recovery206 bytes / 32 events |
+0.0640.061–0.066 |
+0.0460.041–0.048 |
+1.38x |
|---|
+Measurement conditions
CPython 3.14.7, Windows-11-10.0.26300-SP0, 8 logical CPUs.
+Intel64 Family 6 Model 165 Stepping 2, GenuineIntel. Timer: perf_counter_ns. Parser construction, event dispatch and record creation are timed. Exact JSON comparison runs outside the timer. Paired order alternates. Garbage collection is disabled during each timed run and restored afterward. This is one machine, without process isolation or a CPU-affinity setting. No serial device, electrical timing or hardware speed is measured.
+cProfile hotspots, bulk workload
A separate instrumented run. Cumulative times overlap and must not be added or compared directly with the uninstrumented timing table.
bytewise_reference
| Function | Calls | Cumulative ms |
|---|
| profiler.py:73 run_once | 1 | 84.303 |
| examples.py:25 feed | 19 | 84.164 |
| __init__.py:304 loads | 2,048 | 18.016 |
| ~:0 <method 'append' of 'bytearray' objects> | 74,691 | 12.599 |
| ~:0 <built-in method builtins.len> | 78,787 | 9.845 |
| decoder.py:340 decode | 2,048 | 9.016 |
| __init__.py:248 detect_encoding | 2,048 | 4.146 |
chunk_scanning
| Function | Calls | Cumulative ms |
|---|
| profiler.py:73 run_once | 1 | 32.613 |
| optimized.py:16 feed | 19 | 32.504 |
| __init__.py:304 loads | 2,048 | 17.401 |
| decoder.py:340 decode | 2,048 | 8.779 |
| __init__.py:248 detect_encoding | 2,048 | 3.973 |
| decoder.py:351 raw_decode | 2,048 | 2.623 |
| ~:0 <method 'match' of 're.Pattern' objects> | 4,096 | 1.709 |
+Fixture and parser SHA256 evidence
Fixture digests cover canonical event bytes, virtual timestamps, expected records and name. Source digests cover the two class definitions; the alternative inherits timer and EOF handling from the reference.
Parser sources
c9562cf89b2049d0b7299cf7ab857b008e0efb6de0fc4fa1dfdecf4abfda73e7da86bce5a1d40f33b62efb4b8c088be1122c49934bd3ff718d8bbb504113a7ac- bulk-4096
cc202aae41aa6da835ca9f34f99acee02936d6e0afdaa376ecff663a94b890f8 - fragmented-1-2-7-3
579d413e5b2fb5a10c22ca452acb358f6728251da0a368b4a4267de214513694 - bytewise
33172f6e1f12446c27e1ff3e3e03a833fc3bf28c4aff577ffef7913f881d2d92 - fault-recovery
03df6f9982da793b55f4d70c410c5afb95372f1b3fdd42d5857be84082f5d0e1
+
\ No newline at end of file
diff --git a/docs/profile-sample.json b/docs/profile-sample.json
new file mode 100644
index 0000000..b84c26f
--- /dev/null
+++ b/docs/profile-sample.json
@@ -0,0 +1,684 @@
+{
+ "schema_version": 1,
+ "created_utc": "2026-10-18T00:14:59.200252+00:00",
+ "data": "synthetic; no hardware or private captures",
+ "passed": true,
+ "parameters": {
+ "records": 2048,
+ "repeats": 9,
+ "warmups": 3
+ },
+ "environment": {
+ "python": "3.14.7",
+ "implementation": "CPython",
+ "platform": "Windows-11-10.0.26300-SP0",
+ "machine": "AMD64",
+ "processor": "Intel64 Family 6 Model 165 Stepping 2, GenuineIntel",
+ "logical_cpus": 8
+ },
+ "method": {
+ "timer": "perf_counter_ns",
+ "gc": "disabled only during timed runs, restored afterward",
+ "order": "alternating baseline/candidate order per paired repeat",
+ "scope": "fresh parser construction, virtual-event dispatch, emitted records; validation outside timer",
+ "p95": "nearest-rank observed sample",
+ "speed_ratio": "reference median / chunk-scanning median"
+ },
+ "sources": {
+ "reference_sha256": "c9562cf89b2049d0b7299cf7ab857b008e0efb6de0fc4fa1dfdecf4abfda73e7",
+ "candidate_sha256": "da86bce5a1d40f33b62efb4b8c088be1122c49934bd3ff718d8bbb504113a7ac"
+ },
+ "fixture_manifest": [
+ {
+ "name": "json_complete",
+ "sha256": "869943d07227dc653d576ea4e71d7635c2315facab757792d8591dd93b214f4f"
+ },
+ {
+ "name": "json_fragmented",
+ "sha256": "54479abe2c31e8770e82b4e78c9152efb59ad5e913d83df65da884246fe2173b"
+ },
+ {
+ "name": "json_invalid_fields",
+ "sha256": "53616cf53881a9767cddd663544a2eaeb8b11a790f0cf213de1a73591dfb9c80"
+ },
+ {
+ "name": "json_malformed_recovery",
+ "sha256": "b77fa97a74bb5f8c7b3e1e1478b8f25bf82fba5728a7578cc1188f69c0d15c65"
+ },
+ {
+ "name": "json_oversize_recovery",
+ "sha256": "7b0e203e4d1eb0ed854aea21f7755e2a2b1528cab777c637e8d06423b0005d31"
+ },
+ {
+ "name": "json_stale_recovery",
+ "sha256": "d323ea55c6af98adc7fc4a7d552bc7282ab8d83c1c993397c79b950002b20dac"
+ },
+ {
+ "name": "json_truncated",
+ "sha256": "2f15653fd96506df7414d79d9fa0f2d95a62ba95ffbcc5fc8975a58391cbc1cb"
+ },
+ {
+ "name": "bulk-4096",
+ "sha256": "cc202aae41aa6da835ca9f34f99acee02936d6e0afdaa376ecff663a94b890f8"
+ },
+ {
+ "name": "fragmented-1-2-7-3",
+ "sha256": "579d413e5b2fb5a10c22ca452acb358f6728251da0a368b4a4267de214513694"
+ },
+ {
+ "name": "bytewise",
+ "sha256": "33172f6e1f12446c27e1ff3e3e03a833fc3bf28c4aff577ffef7913f881d2d92"
+ },
+ {
+ "name": "fault-recovery",
+ "sha256": "03df6f9982da793b55f4d70c410c5afb95372f1b3fdd42d5857be84082f5d0e1"
+ }
+ ],
+ "gate": {
+ "passed": true,
+ "checks": [
+ {
+ "fixture": "json_complete",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_complete",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_fragmented",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_fragmented",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_invalid_fields",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_invalid_fields",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_malformed_recovery",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_malformed_recovery",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_oversize_recovery",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_oversize_recovery",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_stale_recovery",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_stale_recovery",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_truncated",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "json_truncated",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "bulk-4096",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "bulk-4096",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "fragmented-1-2-7-3",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "fragmented-1-2-7-3",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "bytewise",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "bytewise",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "fault-recovery",
+ "parser": "bytewise_reference",
+ "passed": true,
+ "diff": ""
+ },
+ {
+ "fixture": "fault-recovery",
+ "parser": "chunk_scanning",
+ "passed": true,
+ "diff": ""
+ }
+ ]
+ },
+ "workloads": [
+ {
+ "name": "bulk-4096",
+ "fixture_sha256": "cc202aae41aa6da835ca9f34f99acee02936d6e0afdaa376ecff663a94b890f8",
+ "expected_sha256": "b4725047461702fba1aae180f909476e478d821671bf672a707732bd897932c6",
+ "bytes": 76739,
+ "events": 19,
+ "emitted_records": 2048,
+ "timings": {
+ "bytewise_reference": {
+ "samples_ns": [
+ 18294200,
+ 18034900,
+ 18064000,
+ 20137600,
+ 19678700,
+ 18367800,
+ 18521100,
+ 18556100,
+ 17822400
+ ],
+ "median_ns": 18367800,
+ "min_ns": 17822400,
+ "max_ns": 20137600,
+ "p95_ns": 20137600,
+ "population_stdev_ns": 737415.2124519506
+ },
+ "chunk_scanning": {
+ "samples_ns": [
+ 8669000,
+ 8492700,
+ 8734400,
+ 8600900,
+ 9218000,
+ 9006300,
+ 8605400,
+ 8906100,
+ 8585900
+ ],
+ "median_ns": 8669000,
+ "min_ns": 8492700,
+ "max_ns": 9218000,
+ "p95_ns": 9218000,
+ "population_stdev_ns": 224012.1326079361
+ }
+ },
+ "speed_ratio": 2.1187910947052715,
+ "candidate_time_change_percent": -52.80327529698712
+ },
+ {
+ "name": "fragmented-1-2-7-3",
+ "fixture_sha256": "579d413e5b2fb5a10c22ca452acb358f6728251da0a368b4a4267de214513694",
+ "expected_sha256": "b4725047461702fba1aae180f909476e478d821671bf672a707732bd897932c6",
+ "bytes": 76739,
+ "events": 23612,
+ "emitted_records": 2048,
+ "timings": {
+ "bytewise_reference": {
+ "samples_ns": [
+ 36215500,
+ 27723400,
+ 29831300,
+ 28045400,
+ 27448500,
+ 27378500,
+ 26932600,
+ 27197800,
+ 28638900
+ ],
+ "median_ns": 27723400,
+ "min_ns": 26932600,
+ "max_ns": 36215500,
+ "p95_ns": 36215500,
+ "population_stdev_ns": 2742750.3410956394
+ },
+ "chunk_scanning": {
+ "samples_ns": [
+ 26152000,
+ 26170200,
+ 26486300,
+ 28874000,
+ 24787100,
+ 25049300,
+ 25889200,
+ 25281300,
+ 26099500
+ ],
+ "median_ns": 26099500,
+ "min_ns": 24787100,
+ "max_ns": 28874000,
+ "p95_ns": 28874000,
+ "population_stdev_ns": 1125114.6974727225
+ }
+ },
+ "speed_ratio": 1.0622195827506273,
+ "candidate_time_change_percent": -5.857506655027878
+ },
+ {
+ "name": "bytewise",
+ "fixture_sha256": "33172f6e1f12446c27e1ff3e3e03a833fc3bf28c4aff577ffef7913f881d2d92",
+ "expected_sha256": "b4725047461702fba1aae180f909476e478d821671bf672a707732bd897932c6",
+ "bytes": 76739,
+ "events": 76739,
+ "emitted_records": 2048,
+ "timings": {
+ "bytewise_reference": {
+ "samples_ns": [
+ 43900300,
+ 44104200,
+ 45308500,
+ 44199300,
+ 44661900,
+ 46101700,
+ 46388400,
+ 42690300,
+ 44280200
+ ],
+ "median_ns": 44280200,
+ "min_ns": 42690300,
+ "max_ns": 46388400,
+ "p95_ns": 46388400,
+ "population_stdev_ns": 1085169.7250194394
+ },
+ "chunk_scanning": {
+ "samples_ns": [
+ 56236900,
+ 58603100,
+ 56507000,
+ 56455700,
+ 59571400,
+ 58213400,
+ 57146000,
+ 54888700,
+ 55601800
+ ],
+ "median_ns": 56507000,
+ "min_ns": 54888700,
+ "max_ns": 59571400,
+ "p95_ns": 59571400,
+ "population_stdev_ns": 1422216.2692583748
+ }
+ },
+ "speed_ratio": 0.7836232679137098,
+ "candidate_time_change_percent": 27.61234140767206
+ },
+ {
+ "name": "fault-recovery",
+ "fixture_sha256": "03df6f9982da793b55f4d70c410c5afb95372f1b3fdd42d5857be84082f5d0e1",
+ "expected_sha256": "b981a97015f69f10fcf1e8b6c7eb46ca8d17a82593f2e20190b7526dd340cfed",
+ "bytes": 206,
+ "events": 32,
+ "emitted_records": 6,
+ "timings": {
+ "bytewise_reference": {
+ "samples_ns": [
+ 66400,
+ 66200,
+ 60700,
+ 65500,
+ 63500,
+ 65100,
+ 63700,
+ 63400,
+ 61500
+ ],
+ "median_ns": 63700,
+ "min_ns": 60700,
+ "max_ns": 66400,
+ "p95_ns": 66400,
+ "population_stdev_ns": 1882.669499525725
+ },
+ "chunk_scanning": {
+ "samples_ns": [
+ 46300,
+ 41200,
+ 47700,
+ 45800,
+ 47300,
+ 45700,
+ 46800,
+ 45900,
+ 46800
+ ],
+ "median_ns": 46300,
+ "min_ns": 41200,
+ "max_ns": 47700,
+ "p95_ns": 47700,
+ "population_stdev_ns": 1798.215851035014
+ }
+ },
+ "speed_ratio": 1.3758099352051836,
+ "candidate_time_change_percent": -27.315541601255887
+ }
+ ],
+ "hotspots": {
+ "bytewise_reference": [
+ {
+ "file": "profiler.py",
+ "line": 73,
+ "function": "run_once",
+ "calls": 1,
+ "primitive_calls": 1,
+ "self_seconds": 6.88e-05,
+ "cumulative_seconds": 0.08430270000000001
+ },
+ {
+ "file": "examples.py",
+ "line": 25,
+ "function": "feed",
+ "calls": 19,
+ "primitive_calls": 19,
+ "self_seconds": 0.0411076,
+ "cumulative_seconds": 0.0841639
+ },
+ {
+ "file": "__init__.py",
+ "line": 304,
+ "function": "loads",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0034336,
+ "cumulative_seconds": 0.018015700000000003
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 74691,
+ "primitive_calls": 74691,
+ "self_seconds": 0.012598600000000001,
+ "cumulative_seconds": 0.012598600000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 78787,
+ "primitive_calls": 78787,
+ "self_seconds": 0.0098448,
+ "cumulative_seconds": 0.0098448
+ },
+ {
+ "file": "decoder.py",
+ "line": 340,
+ "function": "decode",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0035480000000000004,
+ "cumulative_seconds": 0.0090159
+ },
+ {
+ "file": "__init__.py",
+ "line": 248,
+ "function": "detect_encoding",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0026084000000000003,
+ "cumulative_seconds": 0.004146100000000001
+ },
+ {
+ "file": "decoder.py",
+ "line": 351,
+ "function": "raw_decode",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0026341000000000003,
+ "cumulative_seconds": 0.0026341000000000003
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 4096,
+ "primitive_calls": 4096,
+ "self_seconds": 0.0017776,
+ "cumulative_seconds": 0.0017776
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.001487,
+ "cumulative_seconds": 0.001487
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.0012211000000000001,
+ "cumulative_seconds": 0.0012211000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.0011408,
+ "cumulative_seconds": 0.0011408
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 4096,
+ "primitive_calls": 4096,
+ "self_seconds": 0.0007267,
+ "cumulative_seconds": 0.0007267
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0006525000000000001,
+ "cumulative_seconds": 0.0006525000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0005408,
+ "cumulative_seconds": 0.0005408
+ }
+ ],
+ "chunk_scanning": [
+ {
+ "file": "profiler.py",
+ "line": 73,
+ "function": "run_once",
+ "calls": 1,
+ "primitive_calls": 1,
+ "self_seconds": 6.36e-05,
+ "cumulative_seconds": 0.0326127
+ },
+ {
+ "file": "optimized.py",
+ "line": 16,
+ "function": "feed",
+ "calls": 19,
+ "primitive_calls": 19,
+ "self_seconds": 0.010183900000000001,
+ "cumulative_seconds": 0.0325039
+ },
+ {
+ "file": "__init__.py",
+ "line": 304,
+ "function": "loads",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0032354000000000003,
+ "cumulative_seconds": 0.017401
+ },
+ {
+ "file": "decoder.py",
+ "line": 340,
+ "function": "decode",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0033580000000000003,
+ "cumulative_seconds": 0.0087788
+ },
+ {
+ "file": "__init__.py",
+ "line": 248,
+ "function": "detect_encoding",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0025133,
+ "cumulative_seconds": 0.003973
+ },
+ {
+ "file": "decoder.py",
+ "line": 351,
+ "function": "raw_decode",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0026225000000000003,
+ "cumulative_seconds": 0.0026225000000000003
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 4096,
+ "primitive_calls": 4096,
+ "self_seconds": 0.0017089000000000002,
+ "cumulative_seconds": 0.0017089000000000002
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 8245,
+ "primitive_calls": 8245,
+ "self_seconds": 0.0013545,
+ "cumulative_seconds": 0.0013545
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.001347,
+ "cumulative_seconds": 0.001347
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.0011575000000000001,
+ "cumulative_seconds": 0.0011575000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 6144,
+ "primitive_calls": 6144,
+ "self_seconds": 0.0011376000000000001,
+ "cumulative_seconds": 0.0011376000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 4096,
+ "primitive_calls": 4096,
+ "self_seconds": 0.0007279000000000001,
+ "cumulative_seconds": 0.0007279000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 2048,
+ "primitive_calls": 2048,
+ "self_seconds": 0.0006188000000000001,
+ "cumulative_seconds": 0.0006188000000000001
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 2065,
+ "primitive_calls": 2065,
+ "self_seconds": 0.0006151,
+ "cumulative_seconds": 0.0006151
+ },
+ {
+ "file": "~",
+ "line": 0,
+ "function": "",
+ "calls": 2065,
+ "primitive_calls": 2065,
+ "self_seconds": 0.0005754,
+ "cumulative_seconds": 0.0005754
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 2154d7c..0faa34a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "uart-fixture-kit"
-version = "0.1.0"
+version = "0.2.0"
description = "Deterministic byte-stream fixtures for testing your own UART parser"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/uart_fixture_kit/__init__.py b/src/uart_fixture_kit/__init__.py
index 2015ec0..a1e2a1e 100644
--- a/src/uart_fixture_kit/__init__.py
+++ b/src/uart_fixture_kit/__init__.py
@@ -7,4 +7,4 @@
"Event", "Fixture", "Parser", "ReplayResult", "load_fixture", "replay",
"chunk_bytes", "drop_bytes", "flip_byte", "schedule",
]
-__version__ = "0.1.0"
+__version__ = "0.2.0"
diff --git a/src/uart_fixture_kit/cli.py b/src/uart_fixture_kit/cli.py
index 5dd628f..2eaf528 100644
--- a/src/uart_fixture_kit/cli.py
+++ b/src/uart_fixture_kit/cli.py
@@ -19,10 +19,28 @@ def main(argv=None) -> int:
run.add_argument("fixture", type=Path)
run.add_argument("--parser", required=True, metavar="MODULE:FACTORY")
run.add_argument("--json", action="store_true")
+ profile = subcommands.add_parser("profile", help="compare fixed synthetic JSON parser workloads")
+ profile.add_argument("--records", type=int, default=2048)
+ profile.add_argument("--repeats", type=int, default=7)
+ profile.add_argument("--warmups", type=int, default=2)
+ profile.add_argument("--output", type=Path, default=Path("uart-profile.json"))
+ profile.add_argument("--html", type=Path, help="also write a standalone HTML report")
+ profile.set_defaults(json=True)
args = parser.parse_args(argv)
reports = []
try:
- if args.command == "demo":
+ if args.command == "profile":
+ from .profiler import run_profile
+ from .profile_report import render_profile_html
+ report = run_profile(args.records, args.repeats, args.warmups)
+ args.output.write_text(json.dumps(report, indent=2, allow_nan=False), encoding="utf-8")
+ if args.html:
+ args.html.write_text(render_profile_html(report), encoding="utf-8")
+ print(json.dumps({"passed": report["passed"], "checks": len(report["gate"]["checks"]),
+ "workloads": len(report["workloads"]), "report": str(args.output),
+ "html": str(args.html) if args.html else None}))
+ return 0 if report["passed"] else 1
+ elif args.command == "demo":
folder = resources.files("uart_fixture_kit").joinpath("fixtures")
for resource in sorted(folder.iterdir(), key=lambda item: item.name):
if not resource.name.endswith(".json"):
diff --git a/src/uart_fixture_kit/optimized.py b/src/uart_fixture_kit/optimized.py
new file mode 100644
index 0000000..9aba3e6
--- /dev/null
+++ b/src/uart_fixture_kit/optimized.py
@@ -0,0 +1,58 @@
+"""Chunk-scanning alternative to the unchanged byte-wise JSON reference parser."""
+
+import json
+import math
+
+from .examples import JsonLineParser, diagnostic
+
+
+class ChunkedJsonLineParser(JsonLineParser):
+ """Same 128-byte line and inactivity contract, scanning chunks with bytes.find.
+
+ advance() and finish() intentionally use the original implementation.
+ This is a synthetic reference example, not a serial transport or standard.
+ """
+
+ def feed(self, data: bytes, now_ms: int):
+ output = []
+ if data:
+ self.last_byte_ms = now_ms
+ offset = 0
+ while offset < len(data):
+ newline = data.find(b"\n", offset)
+ if self.discarding:
+ if newline < 0:
+ break
+ self.discarding = False
+ offset = newline + 1
+ continue
+ end = len(data) if newline < 0 else newline
+ # Check size before slicing so a huge unfinished line cannot grow
+ # the bounded buffer or allocate a second copy of the input.
+ if len(self.buffer) + end - offset > 128:
+ self.buffer.clear()
+ output.append(diagnostic("oversize"))
+ self.discarding = newline < 0
+ if newline < 0:
+ break
+ offset = newline + 1
+ continue
+ self.buffer.extend(data[offset:end])
+ if newline < 0:
+ break
+ line = bytes(self.buffer)
+ self.buffer.clear()
+ offset = newline + 1
+ try:
+ value = json.loads(line)
+ except (ValueError, UnicodeDecodeError):
+ output.append(diagnostic("invalid_json"))
+ continue
+ if (not isinstance(value, dict)
+ or type(value.get("seq")) is not int or not 0 <= value["seq"] <= 255
+ or type(value.get("value")) not in (int, float)
+ or not math.isfinite(value["value"]) or value.get("unit") != "C"):
+ output.append(diagnostic("invalid_record"))
+ continue
+ output.append({"kind": "message", "seq": value["seq"], "value": value["value"], "unit": "C"})
+ return output
diff --git a/src/uart_fixture_kit/profile_report.py b/src/uart_fixture_kit/profile_report.py
new file mode 100644
index 0000000..f1e0b0a
--- /dev/null
+++ b/src/uart_fixture_kit/profile_report.py
@@ -0,0 +1,42 @@
+"""Standalone HTML view of measured profile JSON. No scripts or network calls."""
+
+from html import escape
+
+
+def render_profile_html(report: dict) -> str:
+ rows = []
+ for workload in report["workloads"]:
+ reference = workload["timings"]["bytewise_reference"]
+ candidate = workload["timings"]["chunk_scanning"]
+ ratio = workload["speed_ratio"]
+ def milliseconds(value):
+ return f"{value / 1_000_000:.3f}"
+ rows.append(f'''| {escape(workload['name'])}{workload['bytes']:,} bytes / {workload['events']:,} events |
+{milliseconds(reference['median_ns'])}{milliseconds(reference['min_ns'])}–{milliseconds(reference['max_ns'])} |
+{milliseconds(candidate['median_ns'])}{milliseconds(candidate['min_ns'])}–{milliseconds(candidate['max_ns'])} |
+{f'{ratio:.2f}x' if ratio is not None else 'N/A'} |
''')
+ environment = report["environment"]
+ params = report["parameters"]
+ passed = sum(check["passed"] for check in report["gate"]["checks"])
+ hashes = "".join(f"{escape(w['name'])}{escape(w['fixture_sha256'])}"
+ for w in report["workloads"])
+ hotspots = []
+ for name, values in report["hotspots"].items():
+ entries = "".join(f"| {escape(item['file'])}:{item['line']} {escape(item['function'])} | "
+ f"{item['calls']:,} | {item['cumulative_seconds'] * 1000:.3f} |
"
+ for item in values[:7])
+ hotspots.append(f"{escape(name)}
| Function | Calls | Cumulative ms |
{entries}
")
+ return f'''
+UART Parse Profileruart-fixture-kit / Parse Profiler{escape(report['created_utc'])}
+Parser output first.
Timing second.
Synthetic byte streams. Virtual arrival times. Actual local measurements.
+The unchanged byte-wise JSON reference parser is compared with a chunk-scanning alternative. Every timed run must preserve the expected records, including diagnostics.
+{passed} / {len(report['gate']['checks'])} correctness checks {'passed' if report['passed'] else 'passed; timing withheld'}Bundled JSON fixtures and four generated workloads run through both parsers before measurement.
+Paired timing results
Median milliseconds. The smaller line shows min–max over {params['repeats']} measured runs after {params['warmups']} warm-ups. Ratio = reference median / chunk-scanning median. Values below 1 mean the alternative was slower.
+| Workload | Reference ms | Chunk scanning ms | Ratio |
{''.join(rows)}
+Measurement conditions
{escape(environment['implementation'])} {escape(environment['python'])}, {escape(environment['platform'])}, {environment['logical_cpus']} logical CPUs.
+{escape(environment['processor'])}. Timer: perf_counter_ns. Parser construction, event dispatch and record creation are timed. Exact JSON comparison runs outside the timer. Paired order alternates. Garbage collection is disabled during each timed run and restored afterward. This is one machine, without process isolation or a CPU-affinity setting. No serial device, electrical timing or hardware speed is measured.
+cProfile hotspots, bulk workload
A separate instrumented run. Cumulative times overlap and must not be added or compared directly with the uninstrumented timing table.
{''.join(hotspots)}
+Fixture and parser SHA256 evidence
Fixture digests cover canonical event bytes, virtual timestamps, expected records and name. Source digests cover the two class definitions; the alternative inherits timer and EOF handling from the reference.
Parser sources
{escape(report['sources']['reference_sha256'])}{escape(report['sources']['candidate_sha256'])}
+'''
diff --git a/src/uart_fixture_kit/profiler.py b/src/uart_fixture_kit/profiler.py
new file mode 100644
index 0000000..6997e48
--- /dev/null
+++ b/src/uart_fixture_kit/profiler.py
@@ -0,0 +1,178 @@
+"""Measured parser comparison on fixed synthetic inputs, gated by exact records."""
+
+import cProfile
+from datetime import datetime, timezone
+import gc
+import hashlib
+from importlib import resources
+import inspect
+import json
+import math
+import os
+from pathlib import Path
+import platform
+import pstats
+import statistics
+from time import perf_counter_ns
+
+from .core import Event, Fixture, load_fixture, replay
+from .examples import JsonLineParser
+from .faults import schedule
+from .optimized import ChunkedJsonLineParser
+
+
+def canonical(value) -> bytes:
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
+
+
+def sha(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def fixture_digest(fixture: Fixture) -> str:
+ return sha(canonical({"name": fixture.name, "events": [
+ {"at_ms": event.at_ms, "hex": event.data.hex()} for event in fixture.events
+ ], "end_ms": fixture.end_ms, "expected": fixture.expected}))
+
+
+def build_workloads(records: int = 2048) -> tuple[Fixture, ...]:
+ if type(records) is not int or not 1 <= records <= 10000:
+ raise ValueError("records must be an integer from 1 to 10000")
+ expected = [{"kind": "message", "seq": i % 256,
+ "value": ((i % 7001) - 3500) / 100, "unit": "C"} for i in range(records)]
+ stream = b"".join(canonical({k: value[k] for k in ("seq", "value", "unit")}) + b"\n"
+ for value in expected)
+ workloads = []
+ for name, sizes in (("bulk-4096", [4096]), ("fragmented-1-2-7-3", [1, 2, 7, 3]), ("bytewise", [1])):
+ events = schedule(stream, sizes)
+ workloads.append(Fixture(name, events, events[-1].at_ms + 1, expected))
+ # Explicit fault oracle: a partial record expires, then malformed bytes,
+ # an oversized line and a bad unit precede a valid record and EOF fragment.
+ good = b'{"seq":7,"value":12.5,"unit":"C"}\n'
+ recovery = b'\xff\n' + b'x' * 129 + b'\n' + b'{"seq":1,"value":2,"unit":"mV"}\n' + good
+ tail = schedule(recovery, [2, 17, 1], start_ms=51)
+ events = (Event(0, b'{"seq":'), Event(50, b'')) + tail + (Event(tail[-1].at_ms + 1, b'{'),)
+ expected_faults = [{"kind": "diagnostic", "code": code} for code in
+ ("stale_partial", "invalid_json", "oversize", "invalid_record")]
+ expected_faults += [{"kind": "message", "seq": 7, "value": 12.5, "unit": "C"},
+ {"kind": "diagnostic", "code": "truncated"}]
+ workloads.append(Fixture("fault-recovery", events, events[-1].at_ms + 1, expected_faults))
+ return tuple(workloads)
+
+
+def bundled_json_fixtures() -> tuple[Fixture, ...]:
+ folder = resources.files("uart_fixture_kit").joinpath("fixtures")
+ fixtures = []
+ for resource in sorted(folder.iterdir(), key=lambda item: item.name):
+ if resource.name.startswith("json_") and resource.name.endswith(".json"):
+ with resources.as_file(resource) as path:
+ fixtures.append(load_fixture(path))
+ return tuple(fixtures)
+
+
+def run_once(fixture: Fixture, factory) -> list[dict]:
+ """Time construction, dispatch and emitted records, without JSON comparison."""
+ parser = factory()
+ records = []
+ for event in fixture.events:
+ records.extend(parser.advance(event.at_ms))
+ records.extend(parser.feed(event.data, event.at_ms))
+ records.extend(parser.advance(fixture.end_ms))
+ records.extend(parser.finish(fixture.end_ms))
+ return records
+
+
+def validate_parsers(fixtures, factories) -> dict:
+ checks = []
+ for fixture in fixtures:
+ for name, factory in factories.items():
+ result = replay(fixture, factory())
+ checks.append({"fixture": fixture.name, "parser": name, "passed": result.passed,
+ "diff": result.diff if not result.passed else ""})
+ return {"passed": all(item["passed"] for item in checks), "checks": checks}
+
+
+def _measure(fixture, factory) -> tuple[int, list[dict]]:
+ enabled = gc.isenabled()
+ gc.disable()
+ try:
+ started = perf_counter_ns()
+ records = run_once(fixture, factory)
+ elapsed = perf_counter_ns() - started
+ finally:
+ if enabled:
+ gc.enable()
+ return elapsed, records
+
+
+def summarize(samples: list[int]) -> dict:
+ ordered = sorted(samples)
+ return {"samples_ns": samples, "median_ns": statistics.median(samples),
+ "min_ns": min(samples), "max_ns": max(samples),
+ "p95_ns": ordered[math.ceil(0.95 * len(samples)) - 1],
+ "population_stdev_ns": statistics.pstdev(samples)}
+
+
+def hotspots(fixture, factory) -> list[dict]:
+ profile = cProfile.Profile()
+ records = profile.runcall(run_once, fixture, factory)
+ if canonical(records) != canonical(fixture.expected):
+ raise ValueError("Profiled run changed parser output")
+ stats = pstats.Stats(profile)
+ rows = []
+ for (filename, line, function), (primitive, calls, total, cumulative, _) in stats.stats.items():
+ rows.append({"file": Path(filename).name, "line": line, "function": function,
+ "calls": calls, "primitive_calls": primitive,
+ "self_seconds": total, "cumulative_seconds": cumulative})
+ return sorted(rows, key=lambda row: row["cumulative_seconds"], reverse=True)[:15]
+
+
+def run_profile(records: int = 2048, repeats: int = 7, warmups: int = 2) -> dict:
+ if type(repeats) is not int or not 3 <= repeats <= 31:
+ raise ValueError("repeats must be an integer from 3 to 31")
+ if type(warmups) is not int or not 1 <= warmups <= 10:
+ raise ValueError("warmups must be an integer from 1 to 10")
+ workloads = build_workloads(records)
+ factories = {"bytewise_reference": JsonLineParser, "chunk_scanning": ChunkedJsonLineParser}
+ fixtures = bundled_json_fixtures() + workloads
+ gate = validate_parsers(fixtures, factories)
+ report = {"schema_version": 1, "created_utc": datetime.now(timezone.utc).isoformat(),
+ "data": "synthetic; no hardware or private captures", "passed": gate["passed"],
+ "parameters": {"records": records, "repeats": repeats, "warmups": warmups},
+ "environment": {"python": platform.python_version(), "implementation": platform.python_implementation(),
+ "platform": platform.platform(), "machine": platform.machine(),
+ "processor": platform.processor(), "logical_cpus": os.cpu_count()},
+ "method": {"timer": "perf_counter_ns", "gc": "disabled only during timed runs, restored afterward",
+ "order": "alternating baseline/candidate order per paired repeat",
+ "scope": "fresh parser construction, virtual-event dispatch, emitted records; validation outside timer",
+ "p95": "nearest-rank observed sample", "speed_ratio": "reference median / chunk-scanning median"},
+ "sources": {"reference_sha256": sha(inspect.getsource(JsonLineParser).encode("utf-8")),
+ "candidate_sha256": sha(inspect.getsource(ChunkedJsonLineParser).encode("utf-8"))},
+ "fixture_manifest": [{"name": fixture.name, "sha256": fixture_digest(fixture)} for fixture in fixtures],
+ "gate": gate, "workloads": [], "hotspots": {}}
+ if not gate["passed"]:
+ return report
+ for fixture in workloads:
+ expected = canonical(fixture.expected)
+ for _ in range(warmups):
+ for factory in factories.values():
+ if canonical(run_once(fixture, factory)) != expected:
+ raise ValueError("Warm-up output changed after the correctness gate")
+ samples = {name: [] for name in factories}
+ for repeat in range(repeats):
+ order = list(factories) if repeat % 2 == 0 else list(reversed(factories))
+ for name in order:
+ elapsed, result = _measure(fixture, factories[name])
+ if canonical(result) != expected:
+ raise ValueError("Timed output changed after the correctness gate")
+ samples[name].append(elapsed)
+ timings = {name: summarize(values) for name, values in samples.items()}
+ reference = timings["bytewise_reference"]["median_ns"]
+ candidate = timings["chunk_scanning"]["median_ns"]
+ report["workloads"].append({"name": fixture.name, "fixture_sha256": fixture_digest(fixture),
+ "expected_sha256": sha(expected), "bytes": sum(len(event.data) for event in fixture.events),
+ "events": len(fixture.events), "emitted_records": len(fixture.expected), "timings": timings,
+ "speed_ratio": reference / candidate if candidate else None,
+ "candidate_time_change_percent": (candidate / reference - 1) * 100 if reference else None})
+ report["hotspots"] = {name: hotspots(workloads[0], factory) for name, factory in factories.items()}
+ return report
diff --git a/tests/test_profiler.py b/tests/test_profiler.py
new file mode 100644
index 0000000..7ce34e0
--- /dev/null
+++ b/tests/test_profiler.py
@@ -0,0 +1,114 @@
+import gc
+import json
+import random
+import subprocess
+import sys
+
+import pytest
+
+from uart_fixture_kit import Event, Fixture, replay, schedule
+from uart_fixture_kit.examples import JsonLineParser
+from uart_fixture_kit.optimized import ChunkedJsonLineParser
+from uart_fixture_kit.profiler import (build_workloads, bundled_json_fixtures,
+ canonical, fixture_digest, run_profile, validate_parsers)
+
+
+@pytest.mark.parametrize("fixture", bundled_json_fixtures(), ids=lambda fixture: fixture.name)
+def test_chunk_parser_matches_saved_oracles(fixture):
+ replay(fixture, ChunkedJsonLineParser()).assert_matches()
+
+
+LINE = b'{"seq":1,"value":25.0,"unit":"C"}'
+
+
+@pytest.mark.parametrize("split", range(len(LINE) + 2))
+def test_every_split_preserves_complete_and_empty_lines(split):
+ stream = LINE + b"\n\n" + LINE + b"\n"
+ expected = [{"kind": "message", "seq": 1, "value": 25.0, "unit": "C"},
+ {"kind": "diagnostic", "code": "invalid_json"},
+ {"kind": "message", "seq": 1, "value": 25.0, "unit": "C"}]
+ fixture = Fixture("split", (Event(0, stream[:split]), Event(1, stream[split:])), 2, expected)
+ replay(fixture, ChunkedJsonLineParser()).assert_matches()
+
+
+@pytest.mark.parametrize("length", [127, 128, 129])
+@pytest.mark.parametrize("sizes", [[1], [17, 2, 3], [10000]])
+def test_line_limit_and_recovery(length, sizes):
+ line = LINE + b" " * (length - len(LINE))
+ message = {"kind": "message", "seq": 1, "value": 25.0, "unit": "C"}
+ expected = [message if length <= 128 else {"kind": "diagnostic", "code": "oversize"}, message]
+ events = schedule(line + b"\n" + LINE + b"\n", sizes)
+ fixture = Fixture("limit", events, events[-1].at_ms + 1, expected)
+ replay(fixture, ChunkedJsonLineParser()).assert_matches()
+
+
+def test_huge_partial_is_bounded_and_empty_feed_does_not_reset_timeout():
+ parser = ChunkedJsonLineParser()
+ assert parser.feed(b"x" * 1000000, 0) == [{"kind": "diagnostic", "code": "oversize"}]
+ assert not parser.buffer and parser.discarding
+ assert parser.feed(b"", 49) == []
+ assert parser.advance(50) == [{"kind": "diagnostic", "code": "stale_partial"}]
+ assert not parser.discarding
+ assert parser.feed(LINE + b"\n", 51) == [{"kind": "message", "seq": 1, "value": 25.0, "unit": "C"}]
+
+
+def test_seeded_mixed_stream_matches_reference_for_many_chunk_boundaries():
+ rng = random.Random(1751)
+ stream = (LINE + b"\n" + b"x" * 129 + b"\n\xff\n{broken}\n\n") * 20 + b'{'
+ for _ in range(25):
+ events = schedule(stream, [rng.randint(1, 40) for _ in range(7)])
+ fixture = Fixture("mixed", events, events[-1].at_ms + 1, [])
+ reference = replay(fixture, JsonLineParser()).records
+ assert canonical(replay(fixture, ChunkedJsonLineParser()).records) == canonical(reference)
+
+
+def test_workloads_are_reproducible_and_match_explicit_expected_records():
+ first, second = build_workloads(7), build_workloads(7)
+ assert [fixture_digest(item) for item in first] == [fixture_digest(item) for item in second]
+ assert fixture_digest(first[0]) != fixture_digest(build_workloads(8)[0])
+ for fixture in first:
+ replay(fixture, JsonLineParser()).assert_matches()
+ replay(fixture, ChunkedJsonLineParser()).assert_matches()
+
+
+def test_broken_candidate_prevents_any_timing(monkeypatch):
+ import uart_fixture_kit.profiler as profiler
+ class Broken(JsonLineParser):
+ def feed(self, data, now_ms):
+ return []
+ monkeypatch.setattr(profiler, "ChunkedJsonLineParser", Broken)
+ monkeypatch.setattr(profiler, "_measure", lambda *args: pytest.fail("Timed a rejected parser"))
+ report = run_profile(records=2, repeats=3, warmups=1)
+ assert not report["passed"] and not report["workloads"]
+ assert any(not check["passed"] for check in report["gate"]["checks"])
+
+
+def test_small_profile_has_raw_samples_and_restores_gc():
+ enabled = gc.isenabled()
+ report = run_profile(records=3, repeats=3, warmups=1)
+ assert gc.isenabled() == enabled
+ assert report["passed"] and len(report["gate"]["checks"]) == 22
+ assert len(report["workloads"]) == 4 and len(report["hotspots"]) == 2
+ for workload in report["workloads"]:
+ assert len(workload["fixture_sha256"]) == 64
+ for timing in workload["timings"].values():
+ assert len(timing["samples_ns"]) == 3
+ assert timing["min_ns"] <= timing["median_ns"] <= timing["p95_ns"] <= timing["max_ns"]
+
+
+@pytest.mark.parametrize("kwargs", [{"records": True}, {"records": 0}, {"records": 10001},
+ {"repeats": 2}, {"repeats": True}, {"warmups": 0}])
+def test_profile_bounds(kwargs):
+ with pytest.raises(ValueError):
+ run_profile(**kwargs)
+
+
+def test_profile_cli_writes_json_and_html(tmp_path):
+ target, html = tmp_path / "result.json", tmp_path / "result.html"
+ result = subprocess.run([sys.executable, "-m", "uart_fixture_kit", "profile", "--records", "3",
+ "--repeats", "3", "--warmups", "1", "--output", str(target), "--html", str(html)],
+ capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+ assert json.loads(result.stdout)["passed"]
+ assert json.loads(target.read_text(encoding="utf-8"))["passed"]
+ assert "fault-recovery" in html.read_text(encoding="utf-8")